Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
P
portfolio
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
DatLD
portfolio
Commits
3eb5b4e1
Commit
3eb5b4e1
authored
Jun 30, 2026
by
DatLD
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update portfolio UI
parent
b53436f5
Changes
16
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
593 additions
and
1984 deletions
+593
-1984
globals.css
app/globals.css
+146
-348
not-found.tsx
app/not-found.tsx
+5
-23
page.tsx
app/page.tsx
+1
-6
About.tsx
components/About.tsx
+35
-311
BackToTop.tsx
components/BackToTop.tsx
+2
-4
Contact.tsx
components/Contact.tsx
+69
-245
Experience.tsx
components/Experience.tsx
+33
-149
Footer.tsx
components/Footer.tsx
+28
-56
GithubIcon.tsx
components/GithubIcon.tsx
+7
-0
Hero.tsx
components/Hero.tsx
+70
-257
Navbar.tsx
components/Navbar.tsx
+76
-126
ProjectCard.tsx
components/ProjectCard.tsx
+34
-178
Projects.tsx
components/Projects.tsx
+21
-87
SkeletonCard.tsx
components/SkeletonCard.tsx
+14
-31
Skills.tsx
components/Skills.tsx
+45
-159
TopProgressBar.tsx
components/TopProgressBar.tsx
+7
-4
No files found.
app/globals.css
View file @
3eb5b4e1
This diff is collapsed.
Click to expand it.
app/not-found.tsx
View file @
3eb5b4e1
...
...
@@ -2,35 +2,17 @@ import Link from 'next/link'
export
default
function
NotFound
()
{
return
(
<
div
className=
"min-h-screen flex flex-col items-center justify-center p-6 text-center"
style=
{
{
background
:
'var(--bg)'
}
}
>
<
h1
className=
"text-8xl font-extrabold mb-4 gradient-text"
style=
{
{
fontFamily
:
'var(--font-heading)'
}
}
>
<
div
className=
"flex min-h-screen flex-col items-center justify-center bg-[var(--bg)] p-6 text-center"
>
<
h1
className=
"mb-4 text-7xl font-extrabold text-[var(--primary)] sm:text-8xl"
>
404
</
h1
>
<
p
className=
"text-2xl font-semibold mb-2"
style=
{
{
color
:
'var(--color-text)'
,
fontFamily
:
'var(--font-heading)'
}
}
>
<
p
className=
"mb-2 text-2xl font-semibold text-[var(--text)]"
>
Page not found.
</
p
>
<
p
className=
"
text-sm mb-8 max-w-md"
style=
{
{
color
:
'var(--color-muted)'
}
}
>
<
p
className=
"
mb-8 max-w-md text-sm text-[var(--muted)]"
>
Sorry, the page you are looking for does not exist or has been moved.
</
p
>
<
Link
href=
"/"
className=
"inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-105"
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
color
:
'#0A0F1E'
,
fontFamily
:
'var(--font-heading)'
,
boxShadow
:
'0 0 20px rgba(249,115,22,0.3)'
,
}
}
>
<
Link
href=
"/"
className=
"btn-primary"
>
Back to Home
</
Link
>
</
div
>
...
...
app/page.tsx
View file @
3eb5b4e1
...
...
@@ -14,17 +14,12 @@ export default function Home() {
<>
<
TopProgressBar
/>
<
Navbar
/>
<
main
id=
"main-content"
>
<
main
id=
"main-content"
className=
"overflow-hidden"
>
<
Hero
/>
<
hr
className=
"section-divider"
/>
<
About
/>
<
hr
className=
"section-divider"
/>
<
Skills
/>
<
hr
className=
"section-divider"
/>
<
Projects
/>
<
hr
className=
"section-divider"
/>
<
Experience
/>
<
hr
className=
"section-divider"
/>
<
Contact
/>
</
main
>
<
Footer
/>
...
...
components/About.tsx
View file @
3eb5b4e1
This diff is collapsed.
Click to expand it.
components/BackToTop.tsx
View file @
3eb5b4e1
...
...
@@ -27,11 +27,9 @@ export default function BackToTop() {
animate=
{
{
opacity
:
1
,
scale
:
1
}
}
exit=
{
{
opacity
:
0
,
scale
:
0.5
}
}
onClick=
{
scrollToTop
}
className=
"fixed bottom-6 right-6 z-50 flex
items-center justify-center w-[44px] h-[44px] rounded-full shadow-lg hover:scale-110 transition-transform duration-200 cursor-pointer
"
className=
"fixed bottom-6 right-6 z-50 flex
h-11 w-11 cursor-pointer items-center justify-center rounded-md border border-[var(--border)] bg-white text-[var(--primary)] shadow-lg transition-transform duration-200 hover:scale-105
"
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
color
:
'#0A0F1E'
,
boxShadow
:
'0 0 20px rgba(249,115,22,0.4)'
,
boxShadow
:
'0 12px 30px rgba(15, 23, 42, 0.14)'
,
}
}
aria
-
label=
"Back to top"
>
...
...
components/Contact.tsx
View file @
3eb5b4e1
This diff is collapsed.
Click to expand it.
components/Experience.tsx
View file @
3eb5b4e1
'use client'
import
{
useRef
}
from
'react'
import
{
motion
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
timelineEvents
,
type
TimelineEvent
}
from
'@/lib/data'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
const
badgeStyles
:
Record
<
NonNullable
<
TimelineEvent
[
'badgeColor'
]
>
,
{
bg
:
string
;
color
:
string
;
border
:
string
}
>
=
{
orange
:
{
bg
:
'rgba(249,115,22,0.12)'
,
color
:
'#F97316'
,
border
:
'rgba(249,115,22,0.3)'
},
blue
:
{
bg
:
'rgba(59,130,246,0.12)'
,
color
:
'#60A5FA'
,
border
:
'rgba(59,130,246,0.3)'
},
green
:
{
bg
:
'rgba(34,197,94,0.12)'
,
color
:
'#4ADE80'
,
border
:
'rgba(34,197,94,0.3)'
},
purple
:
{
bg
:
'rgba(168,85,247,0.12)'
,
color
:
'#C084FC'
,
border
:
'rgba(168,85,247,0.3)'
},
}
interface
TimelineNodeProps
{
event
:
TimelineEvent
index
:
number
isInView
:
boolean
isLast
:
boolean
prefersReduced
:
boolean
|
null
}
function
TimelineNode
({
event
,
index
,
isInView
,
isLast
,
prefersReduced
}:
TimelineNodeProps
)
{
const
badgeStyle
=
event
.
badgeColor
?
badgeStyles
[
event
.
badgeColor
]
:
badgeStyles
.
orange
return
(
<
motion
.
div
initial=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
-
32
}
}
animate=
{
isInView
?
prefersReduced
?
{
opacity
:
1
}
:
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
prefersReduced
?
0.3
:
0.55
,
delay
:
index
*
0.15
,
ease
:
'easeOut'
}
}
className=
"relative flex gap-6"
>
{
/* Left — Line & Dot */
}
<
div
className=
"relative flex flex-col items-center"
style=
{
{
minWidth
:
'40px'
}
}
>
{
/* Animated vertical connector */
}
{
!
isLast
&&
(
<
motion
.
div
className=
"absolute top-10 bottom-0 w-[2px]"
initial=
{
{
scaleY
:
0
,
originY
:
0
}
}
animate=
{
isInView
?
{
scaleY
:
1
}
:
{}
}
transition=
{
{
duration
:
0.8
,
delay
:
0.2
+
index
*
0.15
,
ease
:
'easeOut'
}
}
style=
{
{
background
:
'linear-gradient(to bottom, rgba(249,115,22,0.5), rgba(249,115,22,0.05))'
,
left
:
'50%'
,
transform
:
'translateX(-50%)'
,
transformOrigin
:
'top'
,
}
}
/>
)
}
{
/* Dot */
}
<
motion
.
div
initial=
{
{
scale
:
0
}
}
animate=
{
isInView
?
{
scale
:
1
}
:
{}
}
transition=
{
{
duration
:
0.4
,
delay
:
0.1
+
index
*
0.15
,
ease
:
'backOut'
}
}
className=
"relative z-10 w-10 h-10 rounded-full flex items-center justify-center shrink-0"
style=
{
{
background
:
'var(--color-surface)'
,
border
:
'2px solid #F97316'
,
boxShadow
:
'0 0 12px rgba(249,115,22,0.3)'
,
}
}
>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'linear-gradient(135deg, #F97316, #FB923C)'
}
}
/>
</
motion
.
div
>
</
div
>
{
/* Right — Content card */
}
<
div
className=
{
`flex-1 rounded-xl p-5 glass-card ${isLast ? 'mb-0' : 'mb-6'}`
}
style=
{
{
marginTop
:
'5px'
}
}
>
{
/* Date + Badge */
}
<
div
className=
"flex flex-wrap items-center gap-3 mb-3"
>
<
span
className=
"text-xs font-medium"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
event
.
date
}
</
span
>
{
event
.
badge
&&
(
<
span
className=
"px-2.5 py-0.5 rounded-full text-xs font-semibold"
style=
{
{
background
:
badgeStyle
.
bg
,
color
:
badgeStyle
.
color
,
border
:
`1px solid ${badgeStyle.border}`
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
{
event
.
badge
}
</
span
>
)
}
</
div
>
<
h3
className=
"font-semibold text-base mb-1"
style=
{
{
fontFamily
:
'var(--font-heading)'
,
color
:
'var(--color-text)'
}
}
>
{
event
.
title
}
</
h3
>
{
event
.
subtitle
&&
(
<
p
className=
"text-sm mb-1"
style=
{
{
color
:
'var(--primary)'
}
}
>
{
event
.
subtitle
}
</
p
>
)
}
<
p
className=
"text-sm"
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
{
event
.
description
}
</
p
>
</
div
>
</
motion
.
div
>
)
}
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
timelineEvents
}
from
'@/lib/data'
export
default
function
Experience
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
prefersReduced
=
useReducedMotion
()
const
headingVariants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
return
(
<
section
id=
"experience"
ref=
{
sectionRef
}
className=
"py-24"
style=
{
{
background
:
'var(--color-surface)'
}
}
aria
-
label=
"Experience section"
>
<
section
id=
"experience"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Experience section"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
variants=
{
headingVariants
}
custom=
{
0
}
initial=
"hidden"
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"mb-16"
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
}
}
className=
"section-heading"
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
04. Experience
</
p
>
<
h2
className=
"section-title"
>
Where I
'
ve been
</
h2
>
<
div
className=
"mt-3 h-1 w-16 rounded-full"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
<
p
className=
"section-kicker"
>
Experience
</
p
>
<
h2
className=
"section-title"
>
Recent milestones.
</
h2
>
</
motion
.
div
>
{
/* Timeline */
}
<
div
className=
"max-w-3xl"
>
{
timelineEvents
.
map
((
event
,
i
)
=>
(
<
TimelineNode
<
div
className=
"grid gap-3"
>
{
timelineEvents
.
map
((
event
,
index
)
=>
(
<
motion
.
article
key=
{
event
.
id
}
event=
{
event
}
index=
{
i
}
isInView=
{
isInView
}
isLast=
{
i
===
timelineEvents
.
length
-
1
}
prefersReduced=
{
prefersReduced
}
/>
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.38
,
delay
:
index
*
0.05
}
}
className=
"card grid gap-4 p-5 sm:grid-cols-[160px_1fr]"
>
<
div
>
<
p
className=
"text-sm font-bold text-[var(--primary)]"
>
{
event
.
date
}
</
p
>
{
event
.
badge
&&
(
<
span
className=
"mt-3 inline-flex rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-bold text-[var(--muted)]"
>
{
event
.
badge
}
</
span
>
)
}
</
div
>
<
div
>
<
h3
className=
"text-lg"
>
{
event
.
title
}
</
h3
>
{
event
.
subtitle
&&
(
<
p
className=
"mt-1 text-sm font-semibold text-[var(--muted)]"
>
{
event
.
subtitle
}
</
p
>
)
}
<
p
className=
"mt-3 text-sm leading-7 text-[var(--muted-2)]"
>
{
event
.
description
}
</
p
>
</
div
>
</
motion
.
article
>
))
}
</
div
>
</
div
>
...
...
components/Footer.tsx
View file @
3eb5b4e1
'use client'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
navLinks
,
personalInfo
}
from
'@/lib/data'
const
GithubIcon
=
({
size
=
18
}:
{
size
?:
number
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<
path
d=
"M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"
/>
</
svg
>
)
export
default
function
Footer
()
{
const
handleNavClick
=
(
href
:
string
)
=>
{
const
id
=
href
.
slice
(
1
)
document
.
getElementById
(
id
)?.
scrollIntoView
({
behavior
:
'smooth'
})
document
.
getElementById
(
href
.
slice
(
1
))?.
scrollIntoView
({
behavior
:
'smooth'
})
}
return
(
<
footer
className=
"py-10 border-t"
style=
{
{
background
:
'var(--color-surface)'
,
borderColor
:
'rgba(255,255,255,0.06)'
,
}
}
role=
"contentinfo"
>
<
div
className=
"section-container"
>
<
div
className=
"flex flex-col sm:flex-row items-center justify-between gap-6"
>
{
/* Copyright */
}
<
p
className=
"text-sm text-center sm:text-left"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
Built with Next.js · Ho Chi Minh City · 2026
</
p
>
<
footer
className=
"border-t border-[var(--border)] bg-white py-8"
role=
"contentinfo"
>
<
div
className=
"section-container flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
>
<
p
className=
"text-sm text-[var(--muted)]"
>
Built with Next.js and Tailwind CSS. Ho Chi Minh City, 2026.
</
p
>
{
/* Nav Links */
}
<
nav
className=
"flex items-center flex-wrap gap-6 justify-center"
aria
-
label=
"Footer navigation"
>
{
navLinks
.
map
((
link
)
=>
(
<
button
key=
{
link
.
href
}
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
"text-xs uppercase tracking-widest transition-colors duration-200 hover:text-[var(--color-primary)]"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
link
.
label
}
</
button
>
))
}
</
nav
>
<
nav
className=
"flex flex-wrap gap-3"
aria
-
label=
"Footer navigation"
>
{
navLinks
.
map
((
link
)
=>
(
<
button
key=
{
link
.
href
}
type=
"button"
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
"text-sm font-semibold text-[var(--muted)] hover:text-[var(--primary)]"
>
{
link
.
label
}
</
button
>
))
}
</
nav
>
{
/* GitHub Icon */
}
<
a
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
id=
"footer-github"
className=
"flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200 hover:scale-110 hover:text-[var(--color-primary)]"
style=
{
{
background
:
'rgba(255,255,255,0.05)'
,
border
:
'1px solid rgba(255,255,255,0.08)'
,
color
:
'var(--color-muted)'
,
}
}
aria
-
label=
"GitHub profile"
>
<
GithubIcon
size=
{
18
}
/>
</
a
>
</
div
>
<
a
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex h-10 w-10 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted)] hover:text-[var(--primary)]"
aria
-
label=
"GitHub profile"
>
<
GithubIcon
size=
{
18
}
/>
</
a
>
</
div
>
</
footer
>
)
...
...
components/GithubIcon.tsx
0 → 100644
View file @
3eb5b4e1
export
default
function
GithubIcon
({
size
=
18
}:
{
size
?:
number
})
{
return
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
aria
-
hidden=
"true"
>
<
path
d=
"M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"
/>
</
svg
>
)
}
components/Hero.tsx
View file @
3eb5b4e1
This diff is collapsed.
Click to expand it.
components/Navbar.tsx
View file @
3eb5b4e1
'use client'
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
import
{
motion
,
AnimatePresence
}
from
'framer-motion'
import
{
useCallback
,
useEffect
,
useState
}
from
'react'
import
{
Menu
,
X
}
from
'lucide-react'
import
{
navLinks
,
personalInfo
}
from
'@/lib/data'
// ─── Active Section via Intersection Observer ────────────────
function
useActiveSection
(
sectionIds
:
string
[]):
string
{
const
[
active
,
setActive
]
=
useState
<
string
>
(
''
)
const
[
active
,
setActive
]
=
useState
(
''
)
useEffect
(()
=>
{
const
observers
:
IntersectionObserver
[]
=
[]
...
...
@@ -20,26 +18,27 @@ function useActiveSection(sectionIds: string[]): string {
([
entry
])
=>
{
if
(
entry
.
isIntersecting
)
setActive
(
id
)
},
{
rootMargin
:
'-
40
% 0px -55% 0px'
,
threshold
:
0
}
{
rootMargin
:
'-
38
% 0px -55% 0px'
,
threshold
:
0
}
)
observer
.
observe
(
el
)
observers
.
push
(
observer
)
})
return
()
=>
observers
.
forEach
((
o
)
=>
o
.
disconnect
())
return
()
=>
observers
.
forEach
((
o
bserver
)
=>
observer
.
disconnect
())
},
[
sectionIds
])
return
active
}
export
default
function
Navbar
()
{
const
[
scrolled
,
setScrolled
]
=
useState
(
false
)
const
[
mobileOpen
,
setMobileOpen
]
=
useState
(
false
)
const
sectionIds
=
navLinks
.
map
((
l
)
=>
l
.
href
.
slice
(
1
))
const
[
scrolled
,
setScrolled
]
=
useState
(
false
)
const
sectionIds
=
navLinks
.
map
((
link
)
=>
link
.
href
.
slice
(
1
))
const
active
=
useActiveSection
(
sectionIds
)
const
handleScroll
=
useCallback
(()
=>
{
setScrolled
(
window
.
scrollY
>
60
)
setScrolled
(
window
.
scrollY
>
24
)
},
[])
useEffect
(()
=>
{
...
...
@@ -49,128 +48,79 @@ export default function Navbar() {
const
handleNavClick
=
(
href
:
string
)
=>
{
setMobileOpen
(
false
)
const
id
=
href
.
slice
(
1
)
const
el
=
document
.
getElementById
(
id
)
el
?.
scrollIntoView
({
behavior
:
'smooth'
})
document
.
getElementById
(
href
.
slice
(
1
))?.
scrollIntoView
({
behavior
:
'smooth'
})
}
return
(
<>
<
motion
.
nav
initial=
{
{
y
:
-
80
,
opacity
:
0
}
}
animate=
{
{
y
:
0
,
opacity
:
1
}
}
transition=
{
{
duration
:
0.6
,
ease
:
'easeOut'
}
}
className=
{
`fixed top-0 left-0 right-0 z-50 ${
scrolled ? 'scrolled backdrop-blur-md border-b border-white/5' : 'bg-transparent'
}`
}
style=
{
{
backgroundColor
:
scrolled
?
'rgba(10,15,30,0.92)'
:
'transparent'
,
}
}
>
<
div
className=
"section-container"
>
<
div
className=
"flex items-center justify-between h-16"
>
{
/* Logo Badge */
}
<
a
href=
"#"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
window
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
}
}
className=
"flex items-center gap-2"
aria
-
label=
"Back to top"
>
<
span
className=
"inline-flex items-center justify-center w-10 h-10 rounded-xl font-bold text-sm tracking-widest"
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
color
:
'#0A0F1E'
,
fontFamily
:
'var(--font-heading)'
,
boxShadow
:
'0 0 16px rgba(249,115,22,0.4)'
,
}
}
<
header
className=
{
`fixed inset-x-0 top-0 z-50 border-b transition-colors ${
scrolled ? 'border-[var(--border)] bg-white/92 backdrop-blur' : 'border-transparent bg-white/70 backdrop-blur'
}`
}
>
<
nav
className=
"section-container flex h-16 items-center justify-between"
aria
-
label=
"Main navigation"
>
<
a
href=
"#hero"
onClick=
{
(
event
)
=>
{
event
.
preventDefault
()
window
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
}
}
className=
"flex items-center gap-3 font-semibold text-[var(--text)]"
aria
-
label=
"Back to top"
>
<
span
className=
"flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)] text-sm font-bold text-white"
>
{
personalInfo
.
initials
}
</
span
>
<
span
>
{
personalInfo
.
name
}
</
span
>
</
a
>
<
ul
className=
"hidden items-center gap-1 md:flex"
>
{
navLinks
.
map
((
link
)
=>
{
const
sectionId
=
link
.
href
.
slice
(
1
)
const
isActive
=
active
===
sectionId
return
(
<
li
key=
{
link
.
href
}
>
<
button
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
{
`rounded-md px-3 py-2 text-sm font-semibold transition-colors ${
isActive
? 'bg-[var(--surface-2)] text-[var(--primary)]'
: 'text-[var(--muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
}`
}
>
{
link
.
label
}
</
button
>
</
li
>
)
})
}
</
ul
>
<
button
type=
"button"
className=
"flex h-10 w-10 items-center justify-center rounded-md border border-[var(--border)] bg-white text-[var(--text)] md:hidden"
onClick=
{
()
=>
setMobileOpen
((
value
)
=>
!
value
)
}
aria
-
label=
{
mobileOpen
?
'Close menu'
:
'Open menu'
}
aria
-
expanded=
{
mobileOpen
}
>
{
mobileOpen
?
<
X
size=
{
19
}
/>
:
<
Menu
size=
{
19
}
/>
}
</
button
>
</
nav
>
{
mobileOpen
&&
(
<
div
className=
"border-t border-[var(--border)] bg-white md:hidden"
>
<
div
className=
"section-container py-2"
>
{
navLinks
.
map
((
link
)
=>
(
<
button
key=
{
link
.
href
}
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
"block w-full rounded-md px-3 py-3 text-left text-sm font-semibold text-[var(--muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
>
{
personalInfo
.
initials
}
</
span
>
</
a
>
{
/* Desktop Nav Links */
}
<
ul
className=
"hidden md:flex items-center gap-8"
role=
"navigation"
>
{
navLinks
.
map
((
link
)
=>
{
const
sectionId
=
link
.
href
.
slice
(
1
)
const
isActive
=
active
===
sectionId
return
(
<
li
key=
{
link
.
href
}
>
<
button
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
{
`relative text-sm font-medium transition-colors duration-200 group pb-1 ${
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--color-muted2)] hover:text-[var(--color-text)]'
}`
}
>
{
link
.
label
}
{
/* Orange underline */
}
<
span
className=
{
`absolute bottom-0 left-0 h-[2px] rounded-full transition-all duration-300 ${
isActive ? 'w-full' : 'w-0 group-hover:w-full'
}`
}
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
</
button
>
</
li
>
)
})
}
</
ul
>
{
/* Mobile Hamburger */
}
<
button
className=
"md:hidden flex items-center justify-center w-10 h-10 rounded-lg text-[var(--color-muted2)] hover:text-[var(--color-primary)] transition-colors"
onClick=
{
()
=>
setMobileOpen
((
v
)
=>
!
v
)
}
aria
-
label=
{
mobileOpen
?
'Close menu'
:
'Open menu'
}
aria
-
expanded=
{
mobileOpen
}
>
{
mobileOpen
?
<
X
size=
{
22
}
/>
:
<
Menu
size=
{
22
}
/>
}
</
button
>
{
link
.
label
}
</
button
>
))
}
</
div
>
</
div
>
</
motion
.
nav
>
{
/* Mobile Menu Dropdown */
}
<
AnimatePresence
>
{
mobileOpen
&&
(
<
motion
.
div
key=
"mobile-menu"
initial=
{
{
opacity
:
0
,
y
:
-
20
}
}
animate=
{
{
opacity
:
1
,
y
:
0
}
}
exit=
{
{
opacity
:
0
,
y
:
-
20
}
}
transition=
{
{
duration
:
0.25
,
ease
:
'easeOut'
}
}
className=
"fixed top-16 left-0 right-0 z-40 border-b border-white/5"
style=
{
{
backgroundColor
:
'rgba(10,15,30,0.97)'
,
backdropFilter
:
'blur(16px)'
}
}
>
<
ul
className=
"section-container py-4 flex flex-col gap-1"
role=
"navigation"
>
{
navLinks
.
map
((
link
,
i
)
=>
{
const
sectionId
=
link
.
href
.
slice
(
1
)
const
isActive
=
active
===
sectionId
return
(
<
motion
.
li
key=
{
link
.
href
}
initial=
{
{
opacity
:
0
,
x
:
-
16
}
}
animate=
{
{
opacity
:
1
,
x
:
0
}
}
transition=
{
{
delay
:
i
*
0.05
}
}
>
<
button
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
{
`w-full text-left px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-[var(--color-primary-dim)] text-[var(--color-primary)]'
: 'text-[var(--color-muted2)] hover:bg-white/5 hover:text-[var(--color-text)]'
}`
}
>
{
link
.
label
}
</
button
>
</
motion
.
li
>
)
})
}
</
ul
>
</
motion
.
div
>
)
}
</
AnimatePresence
>
</>
)
}
</
header
>
)
}
components/ProjectCard.tsx
View file @
3eb5b4e1
'use client'
import
{
motion
}
from
'framer-motion'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
type
Project
}
from
'@/lib/data'
const
GithubIcon
=
({
size
=
16
}:
{
size
?:
number
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<
path
d=
"M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"
/>
</
svg
>
)
function
TechBadge
({
label
}:
{
label
:
string
})
{
return
(
<
span
className=
"px-2 py-0.5 rounded text-xs font-medium"
style=
{
{
background
:
'rgba(249,115,22,0.1)'
,
color
:
'var(--color-accent)'
,
border
:
'1px solid rgba(249,115,22,0.2)'
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
{
label
}
</
span
>
)
}
function
JsonPreview
()
{
return
(
<
div
className=
"rounded-lg overflow-hidden text-xs"
style=
{
{
background
:
'#0D1117'
,
border
:
'1px solid rgba(249,115,22,0.15)'
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
<
div
className=
"flex items-center gap-2 px-3 py-2 text-xs"
style=
{
{
background
:
'#161B22'
,
borderBottom
:
'1px solid rgba(255,255,255,0.05)'
,
color
:
'var(--color-muted)'
}
}
>
<
span
style=
{
{
color
:
'#22c55e'
}
}
>
GET
</
span
>
<
span
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
/api/orders/123
</
span
>
<
span
className=
"ml-auto"
style=
{
{
color
:
'#22c55e'
}
}
>
200 OK
</
span
>
</
div
>
<
div
className=
"px-4 py-3 space-y-0.5"
style=
{
{
lineHeight
:
1.7
}
}
>
<
div
style=
{
{
color
:
'#E6EDF3'
}
}
>
{
`{`
}<
/
div
>
<
JsonLine
k=
"orderId"
v=
"123"
vColor=
"#79C0FF"
/>
<
JsonLine
k=
"status"
v=
{
`"CONFIRMED"`
}
vColor=
"#A5D6FF"
/>
<
JsonLine
k=
"userId"
v=
"42"
vColor=
"#79C0FF"
/>
<
JsonLine
k=
"total"
v=
"250000"
vColor=
"#79C0FF"
/>
<
JsonLine
k=
"createdAt"
v=
{
`"2026-03-15T10:30:00Z"`
}
vColor=
"#A5D6FF"
/>
<
div
style=
{
{
color
:
'#E6EDF3'
}
}
>
{
`
}
`
}
</
div
>
</
div
>
</
div
>
)
}
function
JsonLine
({
k
,
v
,
vColor
}:
{
k
:
string
;
v
:
string
;
vColor
:
string
})
{
return
(
<
div
className=
"pl-4"
>
<
span
style=
{
{
color
:
'#7EE787'
}
}
>
"
{
k
}
"
</
span
>
<
span
style=
{
{
color
:
'#E6EDF3'
}
}
>
:
</
span
>
<
span
style=
{
{
color
:
vColor
}
}
>
{
v
}
</
span
>
<
span
style=
{
{
color
:
'#E6EDF3'
}
}
>
,
</
span
>
</
div
>
)
}
export
interface
ProjectCardProps
{
project
:
Project
index
:
number
isInView
:
boolean
large
?:
boolean
tall
?:
boolean
}
export
default
function
ProjectCard
({
project
,
index
,
isInView
,
large
=
false
,
tall
=
false
}:
ProjectCardProps
)
{
export
default
function
ProjectCard
({
project
,
index
,
isInView
}:
ProjectCardProps
)
{
return
(
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
30
}
}
<
motion
.
article
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.6
,
delay
:
index
*
0.1
}
}
className=
{
`project-card group relative rounded-2xl overflow-hidden flex flex-col ${
large ? 'bento-featured' : tall ? 'bento-tall' : ''
}`
}
style=
{
{
minHeight
:
tall
?
'420px'
:
'auto'
,
}
}
transition=
{
{
duration
:
0.4
,
delay
:
index
*
0.06
}
}
className=
"card flex h-full flex-col p-5"
>
<
div
aria
-
hidden=
"true"
className=
"absolute top-0 left-0 right-0 h-[2px] rounded-t-2xl"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
<
div
className=
"p-6 flex flex-col flex-1"
>
<
div
className=
"flex items-start justify-between mb-4"
>
<
div
className=
"flex-1 pr-4"
>
<
h3
className=
{
`font-bold mb-1 ${large ? 'text-xl' : 'text-base'} group-hover:text-[var(--color-primary)] transition-colors`
}
style=
{
{
fontFamily
:
'var(--font-heading)'
,
color
:
'var(--color-text)'
}
}
>
{
project
.
name
}
</
h3
>
{
project
.
role
&&
(
<
p
className=
"text-xs mb-1"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
project
.
role
}
</
p
>
)
}
</
div
>
<
span
className=
"shrink-0 px-2 py-1 rounded-md text-xs font-medium"
style=
{
{
background
:
'rgba(255,255,255,0.05)'
,
color
:
'var(--color-muted)'
,
border
:
'1px solid rgba(255,255,255,0.08)'
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
{
project
.
period
.
split
(
'–'
)[
0
].
trim
()
}
</
span
>
</
div
>
<
p
className=
{
`leading-relaxed flex-1 mb-5 ${large ? 'text-sm' : 'text-sm'}`
}
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
{
project
.
description
}
</
p
>
{
project
.
variant
===
'featured'
&&
(
<
div
className=
"mb-5"
>
<
JsonPreview
/>
</
div
>
)
}
<
div
className=
"flex items-end justify-between gap-3 mt-auto"
>
<
div
className=
"flex flex-wrap gap-1.5"
>
{
project
.
stack
.
slice
(
0
,
large
?
project
.
stack
.
length
:
3
).
map
((
tech
)
=>
(
<
TechBadge
key=
{
tech
}
label=
{
tech
}
/>
))
}
{
!
large
&&
project
.
stack
.
length
>
3
&&
(
<
span
className=
"px-2 py-0.5 rounded text-xs"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
+
{
project
.
stack
.
length
-
3
}
</
span
>
)
}
</
div
>
{
project
.
githubUrl
&&
(
<
a
href=
{
project
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
id=
{
`project-github-${project.id}`
}
className=
"shrink-0 flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200 hover:scale-110"
style=
{
{
background
:
'rgba(249,115,22,0.1)'
,
border
:
'1px solid rgba(249,115,22,0.25)'
,
color
:
'var(--color-primary)'
,
}
}
aria
-
label=
{
`GitHub — ${project.name}`
}
>
<
GithubIcon
size=
{
16
}
/>
</
a
>
<
div
className=
"mb-4 flex items-start justify-between gap-4"
>
<
div
>
<
p
className=
"mb-2 text-sm font-bold text-[var(--primary)]"
>
{
project
.
period
}
</
p
>
<
h3
className=
"text-xl"
>
{
project
.
name
}
</
h3
>
{
project
.
role
&&
(
<
p
className=
"mt-2 text-sm font-semibold text-[var(--muted)]"
>
{
project
.
role
}
</
p
>
)
}
</
div
>
</
div
>
<
div
className=
"absolute bottom-0 left-0 right-0 flex items-center justify-center gap-3 py-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300"
style=
{
{
background
:
'linear-gradient(to top, rgba(249,115,22,0.15), transparent)'
}
}
>
{
project
.
githubUrl
&&
(
<
a
href=
{
project
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all hover:scale-105"
style=
{
{
background
:
'linear-gradient(135deg, #F97316, #FB923C)'
,
color
:
'#0A0F1E'
,
fontFamily
:
'var(--font-heading)'
,
}
}
aria
-
label=
{
`View ${project.name} on GitHub`
}
>
<
GithubIcon
size=
{
14
}
/>
View on GitHub
</
a
>
)
}
<
p
className=
"mb-5 flex-1 text-sm leading-7 text-[var(--muted-2)]"
>
{
project
.
description
}
</
p
>
<
div
className=
"mb-5 flex flex-wrap gap-2"
>
{
project
.
stack
.
slice
(
0
,
6
).
map
((
tech
)
=>
(
<
span
key=
{
tech
}
className=
"tag"
>
{
tech
}
</
span
>
))
}
</
div
>
</
motion
.
div
>
{
project
.
githubUrl
&&
(
<
a
href=
{
project
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"inline-flex items-center gap-2 text-sm font-bold text-[var(--primary)] hover:text-[var(--primary-dark)]"
aria
-
label=
{
`View ${project.name} on GitHub`
}
>
<
GithubIcon
size=
{
17
}
/>
View source
</
a
>
)
}
</
motion
.
article
>
)
}
components/Projects.tsx
View file @
3eb5b4e1
'use client'
import
{
useRef
,
Suspense
}
from
'react'
import
dynamic
from
'next/dynamic'
import
{
useRef
}
from
'react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
ExternalLink
}
from
'lucide-react'
import
{
projects
}
from
'@/lib/data'
import
SkeletonGrid
from
'@/components/SkeletonCard'
const
ProjectCard
=
dynamic
(()
=>
import
(
'@/components/ProjectCard'
),
{
loading
:
()
=>
<
div
className=
"h-full w-full min-h-[260px] bg-[var(--surface)] animate-pulse rounded-xl"
/>,
})
const
GithubIcon
=
({
size
=
16
}:
{
size
?:
number
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<
path
d=
"M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"
/>
</
svg
>
)
import
GithubIcon
from
'@/components/GithubIcon'
import
ProjectCard
from
'@/components/ProjectCard'
import
{
personalInfo
,
projects
}
from
'@/lib/data'
export
default
function
Projects
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
featured
=
projects
.
find
((
p
)
=>
p
.
variant
===
'featured'
)
!
const
card2
=
projects
.
find
((
p
)
=>
p
.
variant
===
'card2'
)
!
const
card3
=
projects
.
find
((
p
)
=>
p
.
variant
===
'card3'
)
!
const
card4
=
projects
.
find
((
p
)
=>
p
.
variant
===
'card4'
)
!
return
(
<
section
id=
"projects"
ref=
{
sectionRef
}
className=
"py-24"
aria
-
label=
"Projects section"
>
<
section
id=
"projects"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Projects section"
>
<
div
className=
"section-container"
>
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
}
}
className=
"
mb-16
"
transition=
{
{
duration
:
0.
45
}
}
className=
"
section-heading flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between
"
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
03. Projects
</
p
>
<
h2
className=
"section-title"
>
Things I
'
ve built
</
h2
>
<
div
className=
"mt-3 h-1 w-16 rounded-full"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
</
motion
.
div
>
<
Suspense
fallback=
{
<
SkeletonGrid
/>
}
>
<
div
className=
"bento-grid"
>
<
div
className=
"bento-featured"
style=
{
{
gridColumn
:
'span 2'
,
gridRow
:
'span 1'
}
}
>
<
ProjectCard
project=
{
featured
}
index=
{
0
}
isInView=
{
isInView
}
large
/>
</
div
>
<
div
className=
"bento-card2"
style=
{
{
gridColumn
:
'3'
,
gridRow
:
'span 2'
}
}
>
<
ProjectCard
project=
{
card2
}
index=
{
1
}
isInView=
{
isInView
}
tall
/>
</
div
>
<
div
className=
"bento-card3"
>
<
ProjectCard
project=
{
card3
}
index=
{
2
}
isInView=
{
isInView
}
/>
</
div
>
<
div
className=
"bento-card4"
>
<
ProjectCard
project=
{
card4
}
index=
{
3
}
isInView=
{
isInView
}
/>
</
div
>
<
div
>
<
p
className=
"section-kicker"
>
Projects
</
p
>
<
h2
className=
"section-title"
>
Selected backend work.
</
h2
>
</
div
>
</
Suspense
>
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
16
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.5
,
delay
:
0.6
}
}
className=
"mt-10 flex justify-center"
>
<
a
href=
"https://github.com/datdat054"
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
id=
"projects-github-all"
className=
"flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold transition-all duration-200 hover:scale-105"
style=
{
{
background
:
'rgba(249,115,22,0.1)'
,
color
:
'var(--color-primary)'
,
border
:
'1px solid rgba(249,115,22,0.3)'
,
fontFamily
:
'var(--font-heading)'
,
}
}
className=
"btn-secondary w-fit"
>
<
GithubIcon
size=
{
16
}
/>
View All on GitHub
<
ExternalLink
size=
{
14
}
/>
<
GithubIcon
size=
{
18
}
/>
GitHub
</
a
>
</
motion
.
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
{
projects
.
map
((
project
,
index
)
=>
(
<
ProjectCard
key=
{
project
.
id
}
project=
{
project
}
index=
{
index
}
isInView=
{
isInView
}
/>
))
}
</
div
>
</
div
>
</
section
>
)
...
...
components/SkeletonCard.tsx
View file @
3eb5b4e1
'use client'
export
function
SkeletonCard
(
{
large
=
false
,
tall
=
false
}:
{
large
?:
boolean
;
tall
?:
boolean
}
)
{
export
function
SkeletonCard
()
{
return
(
<
div
className=
"animate-pulse rounded-[12px] p-6 flex flex-col justify-between"
style=
{
{
background
:
'var(--surface)'
,
minHeight
:
tall
?
'420px'
:
large
?
'320px'
:
'260px'
,
border
:
'1px solid rgba(255,255,255,0.05)'
,
}
}
>
<
div
className=
"card flex min-h-[240px] animate-pulse flex-col justify-between p-5"
>
<
div
className=
"space-y-4"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"h-6 w-1/3 bg-white/10 rounded"
/>
<
div
className=
"h-4 w-12 bg-white/10 rounded"
/>
</
div
>
<
div
className=
"h-4 w-3/4 bg-white/10 rounded"
/>
<
div
className=
"h-4 w-1/2 bg-white/10 rounded"
/>
<
div
className=
"h-5 w-1/3 rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-7 w-2/3 rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-4 w-full rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-4 w-4/5 rounded bg-[var(--surface-2)]"
/>
</
div
>
<
div
className=
"flex gap-2 pt-6"
>
<
div
className=
"h-
6 w-16 bg-white/10 rounded
"
/>
<
div
className=
"h-
6 w-20 bg-white/10 rounded
"
/>
<
div
className=
"h-
6 w-14 bg-white/10 rounded
"
/>
<
div
className=
"h-
7 w-16 rounded-full bg-[var(--surface-2)]
"
/>
<
div
className=
"h-
7 w-20 rounded-full bg-[var(--surface-2)]
"
/>
<
div
className=
"h-
7 w-14 rounded-full bg-[var(--surface-2)]
"
/>
</
div
>
</
div
>
)
...
...
@@ -29,19 +20,11 @@ export function SkeletonCard({ large = false, tall = false }: { large?: boolean;
export
default
function
SkeletonGrid
()
{
return
(
<
div
className=
"bento-grid"
>
<
div
className=
"bento-featured"
style=
{
{
gridColumn
:
'span 2'
,
gridRow
:
'span 1'
}
}
>
<
SkeletonCard
large
/>
</
div
>
<
div
className=
"bento-card2"
style=
{
{
gridColumn
:
'3'
,
gridRow
:
'span 2'
}
}
>
<
SkeletonCard
tall
/>
</
div
>
<
div
className=
"bento-card3"
>
<
SkeletonCard
/>
</
div
>
<
div
className=
"bento-card4"
>
<
SkeletonCard
/>
</
div
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
SkeletonCard
/>
<
SkeletonCard
/>
<
SkeletonCard
/>
<
SkeletonCard
/>
</
div
>
)
}
components/Skills.tsx
View file @
3eb5b4e1
'use client'
import
{
useRef
,
useState
}
from
'react'
import
{
motion
,
AnimatePresence
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
import
{
skillCategories
,
type
SkillTab
,
type
Skill
}
from
'@/lib/data'
import
{
motion
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
skillCategories
,
type
SkillTab
}
from
'@/lib/data'
// ─── Skill Progress Bar ───────────────────────────────────────
function
SkillBar
({
skill
,
trigger
,
index
,
prefersReduced
,
}:
{
skill
:
Skill
trigger
:
boolean
index
:
number
prefersReduced
:
boolean
|
null
})
{
const
variants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
return
(
<
motion
.
div
variants=
{
variants
}
custom=
{
index
}
initial=
"hidden"
animate=
{
trigger
?
'visible'
:
'hidden'
}
className=
"skill-card group p-4"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
div
className=
"flex items-center gap-2"
>
<
span
className=
"w-2 h-2 rounded-full shrink-0"
style=
{
{
background
:
'linear-gradient(135deg, #F97316, #FB923C)'
}
}
/>
<
span
className=
"text-sm font-medium"
style=
{
{
color
:
'var(--color-text)'
,
fontFamily
:
'var(--font-body)'
}
}
>
{
skill
.
name
}
</
span
>
</
div
>
<
span
className=
"text-xs font-semibold tabular-nums"
style=
{
{
color
:
'var(--primary)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
skill
.
level
}
%
</
span
>
</
div
>
<
div
className=
"progress-track"
>
<
motion
.
div
className=
"progress-fill"
initial=
{
{
width
:
0
}
}
animate=
{
trigger
?
{
width
:
`${skill.level}%`
}
:
{
width
:
0
}
}
transition=
{
{
duration
:
prefersReduced
?
0
:
1.1
,
delay
:
prefersReduced
?
0
:
index
*
0.08
,
ease
:
[
0.25
,
0.46
,
0.45
,
0.94
],
}
}
/>
</
div
>
</
motion
.
div
>
)
}
// ─── Skills Section ───────────────────────────────────────────
export
default
function
Skills
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
<
SkillTab
>
(
'Backend'
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-
10
0px'
})
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-
8
0px'
})
const
prefersReduced
=
useReducedMotion
()
const
currentCategory
=
skillCategories
.
find
((
c
)
=>
c
.
tab
===
activeTab
)
!
const
tabs
:
SkillTab
[]
=
[
'Backend'
,
'Frontend'
,
'DevOps'
,
'Database'
]
const
headingVariants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
const
currentCategory
=
skillCategories
.
find
((
category
)
=>
category
.
tab
===
activeTab
)
!
return
(
<
section
id=
"skills"
ref=
{
sectionRef
}
className=
"py-24"
style=
{
{
background
:
'var(--color-surface)'
}
}
aria
-
label=
"Skills section"
>
<
section
id=
"skills"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Skills section"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
variants=
{
headingVariants
}
custom=
{
0
}
initial=
"hidden"
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"mb-16"
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
}
}
className=
"section-heading"
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
02. Skills
</
p
>
<
h2
className=
"section-title"
>
What I work with
</
h2
>
<
div
className=
"mt-3 h-1 w-16 rounded-full"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
<
p
className=
"section-kicker"
>
Skills
</
p
>
<
h2
className=
"section-title"
>
Tools I use most often.
</
h2
>
</
motion
.
div
>
{
/* Tab Switcher */
}
<
motion
.
div
variants=
{
headingVariants
}
custom=
{
1
}
initial=
"hidden"
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"flex flex-wrap gap-2 mb-10"
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
,
delay
:
0.08
}
}
className=
"mb-6 flex flex-wrap gap-2"
role=
"tablist"
aria
-
label=
"Skill categories"
>
{
tabs
.
map
((
tab
)
=>
(
<
button
key=
{
tab
}
type=
"button"
role=
"tab"
id=
{
`skills-tab-${tab.toLowerCase()}`
}
aria
-
selected=
{
activeTab
===
tab
}
onClick=
{
()
=>
setActiveTab
(
tab
)
}
className=
"relative px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200 focus:outline-none"
style=
{
{
background
:
activeTab
===
tab
?
'linear-gradient(135deg, #F97316, #FB923C)'
:
'rgba(255,255,255,0.05)'
,
color
:
activeTab
===
tab
?
'#0A0F1E'
:
'var(--color-muted2)'
,
border
:
activeTab
===
tab
?
'none'
:
'1px solid rgba(255,255,255,0.08)'
,
fontFamily
:
'var(--font-heading)'
,
boxShadow
:
activeTab
===
tab
?
'0 0 16px rgba(249,115,22,0.35)'
:
'none'
,
}
}
className=
{
`rounded-md border px-4 py-2 text-sm font-bold transition-colors ${
activeTab === tab
? 'border-[var(--primary)] bg-[var(--primary)] text-white'
: 'border-[var(--border)] bg-white text-[var(--muted)] hover:text-[var(--text)]'
}`
}
>
{
tab
}
</
button
>
))
}
</
motion
.
div
>
{
/* Skills Grid — AnimatePresence tab slide */
}
<
AnimatePresence
mode=
"wait"
>
<
motion
.
div
key=
{
activeTab
}
initial=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
16
}
}
animate=
{
prefersReduced
?
{
opacity
:
1
}
:
{
opacity
:
1
,
x
:
0
}
}
exit=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
-
16
}
}
transition=
{
{
duration
:
prefersReduced
?
0.2
:
0.22
}
}
role=
"tabpanel"
aria
-
labelledby=
{
`skills-tab-${activeTab.toLowerCase()}`
}
className=
"grid grid-cols-1 sm:grid-cols-2 gap-x-12 gap-y-6"
>
{
currentCategory
.
skills
.
map
((
skill
,
i
)
=>
(
<
SkillBar
key=
{
`${activeTab}-${skill.name}`
}
skill=
{
skill
}
trigger=
{
isInView
}
index=
{
i
}
prefersReduced=
{
prefersReduced
}
/>
))
}
</
motion
.
div
>
</
AnimatePresence
>
{
/* Languages */
}
<
motion
.
div
variants=
{
headingVariants
}
custom=
{
2
}
initial=
"hidden"
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"mt-16 pt-10 border-t"
style=
{
{
borderColor
:
'rgba(255,255,255,0.06)'
}
}
>
<
p
className=
"text-xs uppercase tracking-widest mb-5 font-medium"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
Languages
</
p
>
<
div
className=
"flex flex-wrap gap-3"
>
{
[
'Java'
,
'SQL'
,
'JavaScript'
,
'Python'
,
'C++'
].
map
((
lang
)
=>
(
<
span
key=
{
lang
}
className=
"px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-200 hover:scale-105"
style=
{
{
background
:
'var(--color-surface2)'
,
color
:
'var(--color-text)'
,
border
:
'1px solid var(--color-border-subtle)'
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
{
lang
}
</
span
>
))
}
</
div
>
</
motion
.
div
>
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
{
currentCategory
.
skills
.
map
((
skill
,
index
)
=>
(
<
motion
.
div
key=
{
`${activeTab}-${skill.name}`
}
initial=
{
{
opacity
:
0
,
y
:
prefersReduced
?
0
:
14
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.35
,
delay
:
index
*
0.03
}
}
className=
"card p-4"
>
<
div
className=
"mb-3 flex items-center justify-between gap-4"
>
<
span
className=
"font-semibold text-[var(--text)]"
>
{
skill
.
name
}
</
span
>
<
span
className=
"text-sm font-bold text-[var(--primary)]"
>
{
skill
.
level
}
%
</
span
>
</
div
>
<
div
className=
"progress-track"
>
<
motion
.
div
className=
"progress-fill"
initial=
{
{
width
:
0
}
}
animate=
{
isInView
?
{
width
:
`${skill.level}%`
}
:
{
width
:
0
}
}
transition=
{
{
duration
:
prefersReduced
?
0
:
0.8
,
delay
:
index
*
0.03
}
}
/>
</
div
>
</
motion
.
div
>
))
}
</
div
>
</
div
>
</
section
>
)
...
...
components/TopProgressBar.tsx
View file @
3eb5b4e1
...
...
@@ -9,9 +9,12 @@ function ProgressBarContent() {
const
[
loading
,
setLoading
]
=
useState
(
false
)
useEffect
(()
=>
{
setLoading
(
true
)
const
start
=
setTimeout
(()
=>
setLoading
(
true
),
0
)
const
timer
=
setTimeout
(()
=>
setLoading
(
false
),
500
)
return
()
=>
clearTimeout
(
timer
)
return
()
=>
{
clearTimeout
(
start
)
clearTimeout
(
timer
)
}
},
[
pathname
,
searchParams
])
if
(
!
loading
)
return
null
...
...
@@ -20,8 +23,8 @@ function ProgressBarContent() {
<
div
className=
"fixed top-0 left-0 right-0 h-0.5 z-[10000] animate-pulse transition-all duration-300"
style=
{
{
background
:
'
linear-gradient(90deg, #F97316, #FB923C
)'
,
boxShadow
:
'0 0 10px
#F97316
'
,
background
:
'
var(--primary
)'
,
boxShadow
:
'0 0 10px
rgba(37, 99, 235, 0.35)
'
,
}
}
/>
)
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment