feat: Add scroll-based VNDK logo animation, interactive flying letters
This commit is contained in:
parent
35b9ffeeba
commit
0aa03f16f7
2 changed files with 140 additions and 18 deletions
31
README.md
31
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# KHOA.VO Portfolio
|
||||
|
||||
Personal portfolio website featuring dual personas (Creative & IT), inspired by Simmonds Ltd design aesthetics.
|
||||
Personal portfolio website featuring dual personas (Creative & IT), with animated scroll-based branding experience.
|
||||
|
||||
## Live Site
|
||||
|
||||
|
|
@ -10,28 +10,24 @@ Personal portfolio website featuring dual personas (Creative & IT), inspired by
|
|||
## Features
|
||||
|
||||
### Creative Side
|
||||
- **Animated VNDK Logo**: Flying letters that deconstruct/reconstruct during scroll
|
||||
- **Scroll-triggered animation**: Letters appear while scrolling, disappear when stopped
|
||||
- **2.5D floating effect**: Letters wave and spread during scroll
|
||||
- **Reconstructed footer**: Full logo appears centered at bottom
|
||||
- **Three viewing modes**: Grid, List, Minimal
|
||||
- **Image effects**: Grayscale + pixelated + blur → Full color on hover
|
||||
- **Enhanced project modal**: Keyboard navigation (ESC, Arrow keys)
|
||||
- **Professional Journey**: Longer, more detailed for HR/readability
|
||||
|
||||
### IT Side
|
||||
- **Retro desktop UI**: Draggable windows
|
||||
- **CRT screen effects**: Scanlines, vignette
|
||||
- **Idle screensaver**: 5s timeout with animated logo
|
||||
- **Idle screensaver**: 10s timeout with animated logo
|
||||
|
||||
### Design
|
||||
- **Simmonds Ltd inspired**: Dark/light theme, grid patterns, phosphor green accents
|
||||
- **Typography**: Syne (display) + IBM Plex Mono
|
||||
- **Default theme**: Light
|
||||
|
||||
### Print CV (Downloadable PDF)
|
||||
- Professional B&W print-friendly A4 format
|
||||
- Two-column layout: Sidebar (contact, skills, education) + Main content
|
||||
- Includes all 8 job experiences from Graphic Artist to AI Creative Lead
|
||||
- Strategic IT Projects section showcasing Full-Stack development skills
|
||||
- Awards & Recognition section
|
||||
- Clean, minimal design optimized for HR/recruiters
|
||||
- **Dual persona**: Creative Portfolio & IT Developer modes
|
||||
- **Dark/light theme**: Toggle between modes
|
||||
- **Grid patterns**: Subtle background textures
|
||||
- **Phosphor green accents**: #00FF94 signature color
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
|
@ -54,10 +50,11 @@ npm run dev
|
|||
npm run build
|
||||
```
|
||||
|
||||
## Print CV
|
||||
## Deployment
|
||||
|
||||
Press the "Download CV" button or use `window.print()` to generate the PDF CV.
|
||||
Select "Save as PDF" in the print dialog.
|
||||
The site deploys automatically via Forgejo CI/CD to:
|
||||
- **Frontend**: https://khoavo.myds.me
|
||||
- **Git**: https://git.khoavo.myds.me/vndangkhoa/kv-cv
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
127
src/App.jsx
127
src/App.jsx
|
|
@ -543,18 +543,42 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => {
|
|||
const [activeProject, setActiveProject] = useState(null);
|
||||
const [visibleProjects, setVisibleProjects] = useState([]);
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [scrollY, setScrollY] = useState(0);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const scrollTimerRef = useRef(null);
|
||||
|
||||
const bgClass = "bg-[var(--bg-primary)]";
|
||||
const textClass = "text-[var(--text-primary)]";
|
||||
const subTextClass = "text-[var(--text-secondary)]";
|
||||
const borderClass = "border-[var(--border)]";
|
||||
|
||||
// Scroll progress (0 to 1)
|
||||
const getScrollProgress = () => {
|
||||
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
|
||||
return maxScroll > 0 ? Math.min(scrollY / maxScroll, 1) : 0;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrollY(window.scrollY);
|
||||
setShowScrollTop(window.scrollY > 400);
|
||||
setIsScrolling(true);
|
||||
|
||||
// Clear existing timer
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current);
|
||||
}
|
||||
|
||||
// Hide letters after 150ms of no scroll
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
setIsScrolling(false);
|
||||
}, 150);
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation for modal
|
||||
|
|
@ -627,6 +651,83 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => {
|
|||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Scroll-based Flying Letters - only show while scrolling */}
|
||||
<div className="fixed inset-0 pointer-events-none z-30 overflow-hidden">
|
||||
{['V', 'N', 'D', 'K'].map((letter, idx) => {
|
||||
const letterColor = ['V', 'N'].includes(letter)
|
||||
? (darkMode ? '#FFFFFF' : '#1A1A1A')
|
||||
: '#00FF94';
|
||||
const progress = getScrollProgress();
|
||||
|
||||
// Spread amount: 0 at start, max at 50%, back to 0 at end
|
||||
const spreadProgress = Math.sin(progress * Math.PI);
|
||||
const spreadAmount = spreadProgress * 180;
|
||||
|
||||
// Move down from nav toward footer
|
||||
const moveDown = progress * 280;
|
||||
|
||||
// Y wave for floating
|
||||
const yWave = Math.sin(progress * Math.PI * 4 + idx * 1.5) * 25;
|
||||
|
||||
// Only visible while scrolling and during scroll movement
|
||||
const opacity = isScrolling && progress > 0.02 && progress < 0.96 ? 1 : 0;
|
||||
|
||||
// Original logo positions spread: V leftmost, N rightmost, D leftmost, K rightmost
|
||||
// Move them outward from their origin positions
|
||||
const offsetX = [0, 0, 0, 0]; // No additional X offset, use spread
|
||||
const basePositions = [
|
||||
{ x: -spreadAmount - 35, y: -45 + moveDown + yWave }, // V - top left
|
||||
{ x: spreadAmount + 35, y: -45 + moveDown - yWave }, // N - top right
|
||||
{ x: -spreadAmount - 35, y: 55 + moveDown + yWave }, // D - bottom left
|
||||
{ x: spreadAmount + 35, y: 55 + moveDown - yWave }, // K - bottom right
|
||||
];
|
||||
|
||||
// Exact paths from VndkLogo
|
||||
const letterPaths = {
|
||||
V: [{ d: "M 15 25 L 30 45 L 45 25", color: letterColor }],
|
||||
N: [{ d: "M 55 45 L 55 25 L 85 45 L 85 25", color: letterColor }],
|
||||
D: [{ d: "M 15 55 L 30 55 A 10 10 0 0 1 30 75 L 15 75 Z", color: letterColor }],
|
||||
K: [
|
||||
{ d: "M 55 55 L 55 75", color: letterColor },
|
||||
{ d: "M 85 55 L 55 65 L 85 75", color: letterColor },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={letter}
|
||||
className="absolute cursor-pointer pointer-events-auto"
|
||||
style={{
|
||||
left: '50%',
|
||||
x: basePositions[idx].x,
|
||||
y: basePositions[idx].y,
|
||||
opacity: opacity,
|
||||
}}
|
||||
whileHover={{ scale: 1.15 }}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-16 h-16 md:w-20 md:h-20"
|
||||
style={{ filter: 'drop-shadow(0 0 8px currentColor)' }}
|
||||
>
|
||||
{letterPaths[letter].map((pathData, pathIdx) => (
|
||||
<path
|
||||
key={pathIdx}
|
||||
d={pathData.d}
|
||||
stroke={pathData.color}
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Back to Top Button */}
|
||||
<AnimatePresence>
|
||||
{showScrollTop && (
|
||||
|
|
@ -876,6 +977,30 @@ const CreativeSide = ({ onBack, onSwitch, darkMode, toggleTheme }) => {
|
|||
|
||||
{/* Contact */}
|
||||
<footer className={`max-w-7xl mx-auto px-6 py-16 border-t ${borderClass}`}>
|
||||
{/* Reconstructed Logo - original size and position */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{
|
||||
opacity: getScrollProgress() > 0.90 ? 1 : 0,
|
||||
scale: getScrollProgress() > 0.90 ? 1 : 0.7,
|
||||
}}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
{/* Single full logo: V N top, D K bottom */}
|
||||
<svg viewBox="0 0 100 100" className="w-24 h-24 md:w-28 md:h-28">
|
||||
{/* V - top left */}
|
||||
<path d="M 15 25 L 30 45 L 45 25" stroke={darkMode ? '#FFFFFF' : '#1A1A1A'} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
{/* N - top right */}
|
||||
<path d="M 55 45 L 55 25 L 85 45 L 85 25" stroke={darkMode ? '#FFFFFF' : '#1A1A1A'} strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
{/* D - bottom left */}
|
||||
<path d="M 15 55 L 30 55 A 10 10 0 0 1 30 75 L 15 75 Z" stroke="#00FF94" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
{/* K - bottom right */}
|
||||
<path d="M 55 55 L 55 75 M 85 55 L 55 65 L 85 75" stroke="#00FF94" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h3 className="text-2xl font-serif font-bold mb-2">Let's Create Together</h3>
|
||||
|
|
|
|||
Loading…
Reference in a new issue