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
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
@import
'tailwindcss'
;
/* ============================================================
CSS Custom Properties — Design Tokens
============================================================ */
:root
{
--bg
:
#0A0F1E
;
--surface
:
#111827
;
--surface-2
:
#1A2236
;
--border
:
rgba
(
249
,
115
,
22
,
0.15
);
--primary
:
#F97316
;
--accent
:
#FB923C
;
--text
:
#F1F5F9
;
--muted
:
#64748B
;
--gradient
:
linear-gradient
(
135deg
,
#F97316
,
#FB923C
);
/* Typography */
--font-heading
:
var
(
--font-space
,
var
(
--font-space-grotesk
)),
'Space Grotesk'
,
system-ui
,
sans-serif
;
--font-body
:
var
(
--font-inter
),
'Inter'
,
system-ui
,
sans-serif
;
--font-mono
:
var
(
--font-mono
,
var
(
--font-jetbrains-mono
)),
'JetBrains Mono'
,
'Fira Code'
,
monospace
;
/* Spacing & Radius */
--radius-sm
:
6px
;
--radius-md
:
12px
;
--radius-lg
:
16px
;
--radius-xl
:
24px
;
/* Aliases for backwards compatibility */
--color-bg
:
var
(
--bg
);
--color-surface
:
var
(
--surface
);
--color-surface2
:
var
(
--surface-2
);
--color-primary
:
var
(
--primary
);
--color-accent
:
var
(
--accent
);
--color-text
:
var
(
--text
);
--color-muted
:
var
(
--muted
);
--color-muted2
:
var
(
--muted
);
--bg
:
#f7f8fb
;
--surface
:
#ffffff
;
--surface-2
:
#eef2f7
;
--border
:
#d9e0ea
;
--primary
:
#2563eb
;
--primary-dark
:
#1d4ed8
;
--accent
:
#0f766e
;
--text
:
#111827
;
--muted
:
#5b6472
;
--muted-2
:
#374151
;
--font-heading
:
var
(
--font-space
,
var
(
--font-space-grotesk
)),
system-ui
,
sans-serif
;
--font-body
:
var
(
--font-inter
),
system-ui
,
sans-serif
;
--font-mono
:
var
(
--font-mono
,
var
(
--font-jetbrains-mono
)),
ui-monospace
,
monospace
;
--color-bg
:
var
(
--bg
);
--color-surface
:
var
(
--surface
);
--color-surface2
:
var
(
--surface-2
);
--color-primary
:
var
(
--primary
);
--color-accent
:
var
(
--accent
);
--color-text
:
var
(
--text
);
--color-muted
:
var
(
--muted
);
--color-muted2
:
var
(
--muted-2
);
--color-border-subtle
:
var
(
--border
);
--glow-primary
:
0
0
24px
rgba
(
249
,
115
,
22
,
0.35
);
--glow-sm
:
0
0
12px
rgba
(
249
,
115
,
22
,
0.2
);
}
/* ============================================================
Tailwind v4 Theme Extension
============================================================ */
@theme
inline
{
--color-bg
:
var
(
--color-bg
);
--color-surface
:
var
(
--color-surface
);
--color-surface2
:
var
(
--color-surface2
);
--color-primary
:
var
(
--color-primary
);
--color-accent
:
var
(
--color-accent
);
--color-text
:
var
(
--color-text
);
--color-muted
:
var
(
--color-muted
);
--color-muted2
:
var
(
--color-muted2
);
--font-sans
:
var
(
--font-body
);
--font-mono
:
var
(
--font-mono
);
--color-bg
:
var
(
--color-bg
);
--color-surface
:
var
(
--color-surface
);
--color-surface2
:
var
(
--color-surface2
);
--color-primary
:
var
(
--color-primary
);
--color-accent
:
var
(
--color-accent
);
--color-text
:
var
(
--color-text
);
--color-muted
:
var
(
--color-muted
);
--color-muted2
:
var
(
--color-muted2
);
--font-sans
:
var
(
--font-body
);
--font-mono
:
var
(
--font-mono
);
}
/* ============================================================
Base Reset & Global Styles
============================================================ */
*,
*
::before
,
*
::after
{
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
;
}
html
{
scroll-behavior
:
smooth
;
scroll-padding-top
:
88px
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
}
body
{
background-color
:
var
(
--bg
);
min-height
:
100vh
;
margin
:
0
;
background
:
linear-gradient
(
180deg
,
rgba
(
37
,
99
,
235
,
0.06
),
transparent
320px
),
var
(
--bg
);
color
:
var
(
--text
);
font-family
:
var
(
--font-body
);
font-size
:
1rem
;
line-height
:
1.75
;
min-height
:
100vh
;
line-height
:
1.65
;
overflow-x
:
hidden
;
}
/* ============================================================
Typography Defaults
============================================================ */
h
1
,
h2
,
h3
,
h4
,
h5
,
h6
{
font-family
:
var
(
--font-heading
);
font-weight
:
700
;
line-height
:
1.2
;
h1
,
h2
,
h3
,
h
4
,
h5
,
h6
{
margin
:
0
;
color
:
var
(
--text
);
}
.hero-name
{
font-size
:
clamp
(
2.8rem
,
7vw
,
5.5rem
);
font-weight
:
700
;
font-family
:
var
(
--font-heading
);
}
.section-title
{
font-size
:
clamp
(
1.6rem
,
3.5vw
,
2.25rem
);
font-weight
:
600
;
font-family
:
var
(
--font-heading
);
}
.muted-eyebrow
{
font-size
:
0.8rem
;
letter-spacing
:
0.08em
;
text-transform
:
uppercase
;
color
:
var
(
--muted
);
font-weight
:
700
;
line-height
:
1.12
;
}
p
{
color
:
var
(
--color-muted2
);
line-height
:
1.7
;
}
code
,
pre
,
kbd
{
font-family
:
var
(
--font-mono
);
margin
:
0
;
color
:
var
(
--muted-2
);
}
a
{
...
...
@@ -125,315 +89,149 @@ a {
text-decoration
:
none
;
}
/* ============================================================
Scrollbar Styling
============================================================ */
::-webkit-scrollbar
{
width
:
6px
;
}
::-webkit-scrollbar-track
{
background
:
var
(
--color-bg
);
}
::-webkit-scrollbar-thumb
{
background
:
var
(
--color-primary
);
border-radius
:
3px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
var
(
--color-accent
);
button
,
input
,
textarea
{
font
:
inherit
;
}
/* ============================================================
Selection
============================================================ */
::selection
{
background
:
rgba
(
249
,
115
,
22
,
0.3
);
color
:
var
(
--color-text
);
}
/* ============================================================
Hero Dot-Grid Animation
============================================================ */
@keyframes
gridPan
{
from
{
background-position
:
0
0
;
}
to
{
background-position
:
28px
28px
;
}
background
:
rgba
(
37
,
99
,
235
,
0.16
);
}
.hero-bg
{
position
:
absolute
;
inset
:
0
;
background-image
:
radial-gradient
(
rgba
(
249
,
115
,
22
,
0.18
)
1px
,
transparent
1px
);
background-size
:
28px
28px
;
animation
:
gridPan
8s
linear
infinite
;
}
.hero-bg
::after
{
content
:
""
;
position
:
absolute
;
inset
:
0
;
background-image
:
radial-gradient
(
ellipse
at
center
,
transparent
40%
,
#0A0F1E
100%
);
}
/* ============================================================
Terminal Float Animation
============================================================ */
@keyframes
terminalFloat
{
0
%,
100
%
{
transform
:
translateY
(
0px
);
}
50
%
{
transform
:
translateY
(
-10px
);
}
::-webkit-scrollbar
{
width
:
8px
;
}
.terminal-float
{
animation
:
terminalFloat
4s
ease-in-out
infinite
;
::-webkit-scrollbar-track
{
background
:
var
(
--surface-2
)
;
}
/* ============================================================
Blinking Cursor
============================================================ */
@keyframes
blink
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0
;
}
::-webkit-scrollbar-thumb
{
background
:
#b8c2d2
;
border-radius
:
999px
;
}
.cursor-blink
{
animation
:
blink
1s
step-end
infinite
;
.section-container
{
width
:
min
(
100%
-
2rem
,
1120px
);
margin-inline
:
auto
;
}
/* Typing cursor — orange, 0.7s step-end */
.typing-cursor
{
animation
:
blink
0.7s
step-end
infinite
;
.section-shell
{
padding-block
:
clamp
(
3.5rem
,
7vw
,
5.5rem
);
}
/* ============================================================
Navbar — Scrolled State
============================================================ */
nav
{
transition
:
background-color
0.3s
ease
,
backdrop-filter
0.3s
ease
;
.section-heading
{
margin-bottom
:
2rem
;
}
nav
.scrolled
{
background
:
rgba
(
10
,
15
,
30
,
0.92
)
!important
;
backdrop-filter
:
blur
(
16px
)
!important
;
-webkit-backdrop-filter
:
blur
(
16px
)
!important
;
.section-kicker
{
margin-bottom
:
0.6rem
;
color
:
var
(
--primary
);
font-size
:
0.78rem
;
font-weight
:
700
;
letter-spacing
:
0.08em
;
text-transform
:
uppercase
;
}
/* ============================================================
Scroll-down Bounce
============================================================ */
@keyframes
scrollBounce
{
0
%,
100
%
{
transform
:
translateY
(
0
);
}
50
%
{
transform
:
translateY
(
8px
);
}
.section-title
{
max-width
:
680px
;
font-size
:
clamp
(
1.8rem
,
3.5vw
,
2.8rem
);
letter-spacing
:
0
;
}
.scroll-bounce
{
animation
:
scrollBounce
1.5s
ease-in-out
infinite
;
.card
{
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
background
:
var
(
--surface
);
box-shadow
:
0
14px
32px
rgba
(
15
,
23
,
42
,
0.06
);
}
/* ============================================================
Section Divider
============================================================ */
.section-divider
{
border
:
none
;
height
:
1px
;
background
:
linear-gradient
(
90deg
,
transparent
,
rgba
(
249
,
115
,
22
,
0.25
)
30%
,
rgba
(
249
,
115
,
22
,
0.25
)
70%
,
transparent
);
margin
:
0
;
.pill
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
0.45rem
;
border
:
1px
solid
var
(
--border
);
border-radius
:
999px
;
background
:
#fff
;
color
:
var
(
--muted
);
font-size
:
0.82rem
;
font-weight
:
600
;
line-height
:
1
;
}
.btn-primary
,
.btn-secondary
{
display
:
inline-flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
0.5rem
;
min-height
:
44px
;
border-radius
:
8px
;
padding
:
0.72rem
1.05rem
;
font-size
:
0.92rem
;
font-weight
:
700
;
transition
:
background
160ms
ease
,
border-color
160ms
ease
,
color
160ms
ease
,
transform
160ms
ease
;
}
/* ============================================================
Orange gradient text utility
============================================================ */
.gradient-text
{
background
:
linear-gradient
(
135deg
,
#F97316
0%
,
#FB923C
50%
,
#FED7AA
100%
);
-webkit-background-clip
:
text
;
-webkit-text-fill-color
:
transparent
;
background-clip
:
text
;
background-size
:
200%
200%
;
animation
:
shimmer
4s
ease-in-out
infinite
;
.btn-primary
{
background
:
var
(
--primary
);
color
:
#fff
;
}
@keyframes
shimm
er
{
0
%,
100
%
{
background-position
:
0%
50%
;
}
50
%
{
background-position
:
100%
50%
;
}
.btn-primary
:hov
er
{
background
:
var
(
--primary-dark
);
transform
:
translateY
(
-1px
);
}
/* ============================================================
Glass Card utility
============================================================ */
.glass-card
{
background
:
rgba
(
17
,
24
,
39
,
0.7
);
backdrop-filter
:
blur
(
12px
);
-webkit-backdrop-filter
:
blur
(
12px
);
.btn-secondary
{
border
:
1px
solid
var
(
--border
);
transition
:
border-color
0.3s
ease
,
box-shadow
0.3s
ease
,
transform
0.3s
ease
;
}
.glass-card
:hover
{
border-color
:
var
(
--primary
);
box-shadow
:
var
(
--glow-primary
);
background
:
#fff
;
color
:
var
(
--text
);
}
.project-card
,
.skill-card
{
background
:
rgba
(
17
,
24
,
39
,
0.8
);
border
:
1px
solid
rgba
(
249
,
115
,
22
,
0.15
);
backdrop-filter
:
blur
(
12px
);
border-radius
:
12px
;
transition
:
border-color
0.3s
,
box-shadow
0.3s
;
.btn-secondary
:hover
{
border-color
:
#b7c2d4
;
transform
:
translateY
(
-1px
);
}
.project-card
:hover
,
.skill-card
:hover
{
border-color
:
rgba
(
249
,
115
,
22
,
0.5
);
box-shadow
:
0
0
24px
rgba
(
249
,
115
,
22
,
0.1
);
.tag
{
display
:
inline-flex
;
align-items
:
center
;
border
:
1px
solid
#cfd8e6
;
border-radius
:
999px
;
background
:
#f8fafc
;
color
:
#334155
;
padding
:
0.38rem
0.7rem
;
font-size
:
0.78rem
;
font-weight
:
600
;
}
/* ============================================================
Progress Bar Base
============================================================ */
.progress-track
{
background
:
var
(
--surface-2
);
height
:
6px
;
border-radius
:
3px
;
position
:
relative
;
height
:
6px
;
overflow
:
hidden
;
border-radius
:
999px
;
background
:
#dbe3ee
;
}
.progress-fill
{
background
:
linear-gradient
(
90deg
,
#F97316
,
#FB923C
);
border-radius
:
3px
;
height
:
100%
;
border-radius
:
inherit
;
background
:
var
(
--primary
);
}
/* ============================================================
Section Base
============================================================ */
.section-container
{
max-width
:
1280px
;
margin-left
:
auto
;
margin-right
:
auto
;
padding-left
:
1.5rem
;
padding-right
:
1.5rem
;
}
/* ============================================================
Timeline Connector
============================================================ */
.timeline-line
{
position
:
absolute
;
left
:
19px
;
top
:
0
;
bottom
:
0
;
width
:
2px
;
background
:
linear-gradient
(
to
bottom
,
#F97316
,
rgba
(
249
,
115
,
22
,
0.1
));
}
/* ============================================================
Contact Input Animated Border
============================================================ */
.input-field
{
position
:
relative
;
background
:
transparent
;
border
:
none
;
border-bottom
:
1px
solid
var
(
--color-border-subtle
);
color
:
var
(
--color-text
);
width
:
100%
;
padding
:
0.75rem
0
;
font-family
:
var
(
--font-body
);
font-size
:
0.95rem
;
outline
:
none
;
transition
:
border-color
0.3s
ease
;
}
.input-wrapper
{
position
:
relative
;
}
.input-wrapper
::after
{
content
:
''
;
position
:
absolute
;
bottom
:
0
;
left
:
0
;
width
:
0
;
height
:
2px
;
background
:
linear-gradient
(
90deg
,
#F97316
,
#FB923C
);
transition
:
width
0.4s
cubic-bezier
(
0.4
,
0
,
0.2
,
1
);
}
.input-wrapper
:focus-within::after
{
width
:
100%
;
}
/* ============================================================
Code Block Syntax (IntelliJ Darcula)
============================================================ */
.code-block
{
background
:
#2B2B2B
;
border-radius
:
10px
;
border
:
1px
solid
#3C3F41
;
padding
:
1.25rem
1.5rem
;
font-family
:
"JetBrains Mono"
,
monospace
;
font-size
:
13px
;
line-height
:
1.8
;
}
.line-numbers
{
color
:
#606366
;
margin-right
:
1.5rem
;
user-select
:
none
;
}
.kw
{
color
:
#CC7832
;
}
/* keywords: public, class, private, return, void */
.str
{
color
:
#6A8759
;
}
/* string literals */
.cmt
{
color
:
#808080
;
}
/* comments */
.fn
{
color
:
#FFC66D
;
}
/* method names */
.type
{
color
:
#A9B7C6
;
}
/* types: String, int */
.ann
{
color
:
#BBB529
;
}
/* annotations: @Override */
/* ============================================================
Bento Grid
============================================================ */
.bento-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
1
fr
;
grid-template-rows
:
auto
auto
;
gap
:
1.25rem
;
}
@media
(
max-width
:
1024px
)
{
.bento-grid
{
grid-template-columns
:
1
fr
1
fr
;
grid-template-areas
:
"featured featured"
"card2 card2"
"card3 card4"
;
}
.bento-featured
{
grid-area
:
featured
;
}
.bento-card2
{
grid-area
:
card2
;
}
.bento-card3
{
grid-area
:
card3
;
}
.bento-card4
{
grid-area
:
card4
;
}
*
:focus-visible
{
outline
:
3px
solid
rgba
(
37
,
99
,
235
,
0.35
);
outline-offset
:
3px
;
}
@media
(
max-width
:
640px
)
{
.bento-grid
{
grid-template-columns
:
1
fr
;
grid-template-areas
:
"featured"
"card2"
"card3"
"card4"
;
}
}
/* ============================================================
Responsive Utilities
============================================================ */
@media
(
max-width
:
375px
)
{
.section-container
{
padding-left
:
1rem
;
padding-right
:
1rem
;
width
:
min
(
100%
-
1.25rem
,
1120px
);
}
}
/* ============================================================
Accessibility & Polish
============================================================ */
html
{
scroll-behavior
:
smooth
;
scroll-padding-top
:
80px
;
}
*
:focus-visible
{
outline
:
2px
solid
#F97316
!important
;
outline-offset
:
2px
!important
;
.btn-primary
,
.btn-secondary
{
width
:
100%
;
}
}
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
'use client'
import
{
useEffect
,
useRef
,
useState
}
from
'react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
personalInfo
,
stats
,
type
Stat
}
from
'@/lib/data'
import
{
useRef
}
from
'react'
import
{
personalInfo
,
stats
}
from
'@/lib/data'
// ─── Count-up hook ────────────────────────────────────────────
function
useCountUp
(
target
:
number
,
duration
=
1800
,
trigger
:
boolean
)
{
const
[
count
,
setCount
]
=
useState
(
0
)
const
startRef
=
useRef
<
number
|
null
>
(
null
)
const
rafRef
=
useRef
<
number
|
null
>
(
null
)
useEffect
(()
=>
{
if
(
!
trigger
)
return
startRef
.
current
=
null
const
step
=
(
timestamp
:
number
)
=>
{
if
(
!
startRef
.
current
)
startRef
.
current
=
timestamp
const
elapsed
=
timestamp
-
startRef
.
current
const
progress
=
Math
.
min
(
elapsed
/
duration
,
1
)
// Ease out cubic
const
eased
=
1
-
Math
.
pow
(
1
-
progress
,
3
)
setCount
(
Math
.
floor
(
eased
*
target
))
if
(
progress
<
1
)
{
rafRef
.
current
=
requestAnimationFrame
(
step
)
}
}
rafRef
.
current
=
requestAnimationFrame
(
step
)
return
()
=>
{
if
(
rafRef
.
current
)
cancelAnimationFrame
(
rafRef
.
current
)
}
},
[
trigger
,
target
,
duration
])
return
count
}
// ─── Stat Counter Card ────────────────────────────────────────
function
StatCard
({
stat
,
trigger
}:
{
stat
:
Stat
;
trigger
:
boolean
})
{
const
value
=
useCountUp
(
stat
.
value
,
1600
,
trigger
)
return
(
<
div
className=
"flex flex-col items-center text-center p-5 rounded-xl glass-card"
>
<
span
className=
"text-3xl sm:text-4xl font-bold gradient-text mb-1"
style=
{
{
fontFamily
:
'var(--font-heading)'
}
}
>
{
value
}{
stat
.
suffix
}
</
span
>
<
span
className=
"text-xs uppercase tracking-widest font-medium"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
stat
.
label
}
</
span
>
</
div
>
)
}
// ─── Darcula Code Line ────────────────────────────────────────
interface
CodeLine
{
lineNum
:
number
content
:
React
.
ReactNode
}
function
CodeBlock
()
{
const
codeLines
:
CodeLine
[]
=
[
{
lineNum
:
1
,
content
:
(
<
span
>
<
span
className=
"kw"
>
public class
</
span
>
<
span
className=
"nm"
>
AboutMe
</
span
>
<
span
className=
"nm"
>
{
'{'
}<
/
span
>
</
span
>
),
}
,
{
lineNum
:
2
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
name
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"str"
>
"
Luu Duc Dat
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
3
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
role
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"str"
>
"
Java Backend Developer
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
4
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
location
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"str"
>
"
Ho Chi Minh City
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
5
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
school
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"str"
>
"
PTIT HCMC — GPA 3.0/4.0
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
6
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
[] stack =
{
'{'
}<
/
span
>
</
span
>
),
}
,
{
lineNum
:
7
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"str"
>
"
Spring Boot
"
</
span
>
<
span
className=
"nm"
>
,
</
span
>
<
span
className=
"str"
>
"
Microservices
"
</
span
>
<
span
className=
"nm"
>
,
</
span
>
<
span
className=
"str"
>
"
PostgreSQL
"
</
span
>
</
span
>
),
}
,
{
lineNum
:
8
,
content
:
<
span
>
<
span
className=
"nm"
>
{
'
}
'
}
;
</
span
></
span
>,
}
,
{
lineNum
:
9
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"nm"
>
goal
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"str"
>
"
Senior Backend Engineer in 3 years
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
10
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
private int
</
span
>
<
span
className=
"nm"
>
coffeePerDay
</
span
>
<
span
className=
"nm"
>
=
</
span
>
<
span
className=
"num"
>
3
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
11
,
content
:
<
span
className
=
"nm"
>&
nbsp
;<
/
span
>
}
,
{
lineNum
:
12
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"ann"
>
@Override
</
span
>
</
span
>
),
}
,
{
lineNum
:
13
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
public
</
span
>
<
span
className=
"kw"
>
String
</
span
>
<
span
className=
"mtd"
>
toString
</
span
>
<
span
className=
"nm"
>
()
{
'{'
}<
/
span
>
</
span
>
),
}
,
{
lineNum
:
14
,
content
:
(
<
span
>
{
' '
}
<
span
className=
"kw"
>
return
</
span
>
<
span
className=
"str"
>
"
Always building. Always learning.
"
</
span
>
<
span
className=
"nm"
>
;
</
span
>
</
span
>
),
}
,
{
lineNum
:
15
,
content
:
<
span
>
<
span
className=
"nm"
>
{
'
}
'
}
</
span
></
span
>,
}
,
{
lineNum
:
16
,
content
:
<
span
>
<
span
className=
"nm"
>
{
'
}
'
}
</
span
></
span
>
},
]
return
(
<
div
className=
"code-block rounded-xl overflow-hidden"
>
{
/* Mac-style title bar */
}
<
div
className=
"flex items-center gap-2 px-4 py-3 -mx-6 -mt-5 mb-3"
style=
{
{
background
:
'#1E1E2E'
,
borderBottom
:
'1px solid #3C3F41'
}
}
>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#FF5F57'
}
}
/>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#FEBC2E'
}
}
/>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#28C840'
}
}
/>
<
span
className=
"ml-2 text-xs"
style=
{
{
color
:
'#808080'
,
fontFamily
:
'var(--font-mono)'
}
}
>
AboutMe.java
</
span
>
</
div
>
{
/* Code lines */
}
<
div
className=
"overflow-x-auto"
>
{
codeLines
.
map
((
line
)
=>
(
<
div
key=
{
line
.
lineNum
}
className=
"flex items-start hover:bg-white/5 transition-colors"
>
<
span
className=
"line-numbers select-none text-right w-8 shrink-0 pr-4"
>
{
line
.
lineNum
}
</
span
>
<
span
className=
"whitespace-pre"
>
{
line
.
content
}
</
span
>
</
div
>
))
}
</
div
>
</
div
>
)
}
// ─── About Section ────────────────────────────────────────────
export
default
function
About
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
statsRef
=
useRef
<
HTMLDivElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-100px'
})
const
statsInView
=
useInView
(
statsRef
,
{
once
:
true
,
margin
:
'-50px'
})
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
bioLines
=
personalInfo
.
bio
.
split
(
'
\
n
\
n'
)
return
(
<
section
id=
"about"
ref=
{
sectionRef
}
className=
"
py-24
"
aria
-
label=
"About section"
>
<
section
id=
"about"
ref=
{
sectionRef
}
className=
"
section-shell
"
aria
-
label=
"About section"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
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
"
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
01. About
</
p
>
<
h2
className=
"section-title"
>
Who I Am
</
h2
>
<
div
className=
"mt-3 h-1 w-16 rounded-full"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
<
p
className=
"section-kicker"
>
About
</
p
>
<
h2
className=
"section-title"
>
Backend-focused, practical, and always learning.
</
h2
>
</
motion
.
div
>
{
/* Two-column layout */
}
<
div
className=
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-start"
>
{
/* Left — Text */
}
<
div
className=
"grid gap-6 lg:grid-cols-[1fr_360px]"
>
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
-
30
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
7
,
delay
:
0.2
}
}
className=
"
space-y-5
"
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
45
,
delay
:
0.08
}
}
className=
"
card p-6 sm:p-7
"
>
{
bioLines
.
map
((
line
,
i
)
=>
(
<
p
key=
{
i
}
className=
"text-base leading-relaxed"
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
{
line
}
</
p
>
))
}
<
div
className=
"space-y-5"
>
{
bioLines
.
map
((
line
)
=>
(
<
p
key=
{
line
}
className=
"text-base leading-8 text-[var(--muted-2)]"
>
{
line
}
</
p
>
))
}
</
div
>
{
/* Tags */
}
<
div
className=
"flex flex-wrap gap-2 pt-2"
>
{
[
'OOP'
,
'Clean Code'
,
'API Design'
,
'System Design'
,
'Distributed Systems'
].
map
((
tag
)
=>
(
<
span
key=
{
tag
}
className=
"px-3 py-1 rounded-full text-xs font-medium"
style=
{
{
background
:
'rgba(249,115,22,0.12)'
,
color
:
'var(--primary)'
,
border
:
'1px solid rgba(249,115,22,0.25)'
,
fontFamily
:
'var(--font-mono)'
,
}
}
>
<
div
className=
"mt-7 flex flex-wrap gap-2"
>
{
[
'Java'
,
'Spring Boot'
,
'REST APIs'
,
'Microservices'
,
'Databases'
].
map
((
tag
)
=>
(
<
span
key=
{
tag
}
className=
"tag"
>
{
tag
}
</
span
>
))
}
</
div
>
</
motion
.
div
>
{
/* Right — Code Block */
}
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
30
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.7
,
delay
:
0.35
}
}
>
<
CodeBlock
/>
</
motion
.
div
>
</
div
>
{
/* Stats Row */
}
<
div
ref=
{
statsRef
}
className=
"mt-16"
>
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
animate=
{
stat
sInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
,
delay
:
0.1
}
}
className=
"grid g
rid-cols-3 gap-4 sm:gap-6
"
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
i
sInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
45
,
delay
:
0.16
}
}
className=
"grid g
ap-3
"
>
{
stats
.
map
((
stat
)
=>
(
<
StatCard
key=
{
stat
.
label
}
stat=
{
stat
}
trigger=
{
statsInView
}
/>
<
div
key=
{
stat
.
label
}
className=
"card p-5"
>
<
div
className=
"text-3xl font-bold text-[var(--primary)]"
>
{
stat
.
value
}
{
stat
.
suffix
}
</
div
>
<
div
className=
"mt-1 text-sm font-semibold text-[var(--muted)]"
>
{
stat
.
label
}
</
div
>
</
div
>
))
}
</
motion
.
div
>
</
div
>
...
...
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
'use client'
import
{
useRef
,
useState
}
from
'react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
FormEvent
,
useRef
,
useState
}
from
'react'
import
{
Mail
,
Send
}
from
'lucide-react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
personalInfo
}
from
'@/lib/data'
// ─── SVG Brand Icons ──────────────────────────────────────────
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
>
)
const
LinkedinIcon
=
({
size
=
18
}:
{
size
?:
number
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
<
path
d=
"M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
/>
</
svg
>
)
// ─── Animated Input ────────────────────────────────────────────
interface
InputProps
{
function
Field
({
id
,
label
,
value
,
onChange
,
type
=
'text'
,
multiline
=
false
,
}:
{
id
:
string
label
:
string
type
?:
string
value
:
string
onChange
:
(
v
:
string
)
=>
void
onChange
:
(
value
:
string
)
=>
void
type
?:
string
multiline
?:
boolean
}
function
AnimatedInput
({
id
,
label
,
type
=
'text'
,
value
,
onChange
,
multiline
=
false
}:
InputProps
)
{
const
[
focused
,
setFocused
]
=
useState
(
false
)
const
hasValue
=
value
.
length
>
0
const
baseStyle
:
React
.
CSSProperties
=
{
width
:
'100%'
,
background
:
'transparent'
,
border
:
'none'
,
outline
:
'none'
,
color
:
'var(--color-text)'
,
fontFamily
:
'var(--font-body)'
,
fontSize
:
'0.95rem'
,
paddingTop
:
'1.5rem'
,
paddingBottom
:
'0.5rem'
,
resize
:
'none'
as
const
,
}
const
labelStyle
:
React
.
CSSProperties
=
{
position
:
'absolute'
as
const
,
left
:
0
,
top
:
focused
||
hasValue
?
'0.25rem'
:
'1rem'
,
fontSize
:
focused
||
hasValue
?
'0.7rem'
:
'0.95rem'
,
color
:
focused
?
'var(--color-primary)'
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-body)'
,
transition
:
'all 0.25s cubic-bezier(0.4,0,0.2,1)'
,
pointerEvents
:
'none'
,
letterSpacing
:
focused
||
hasValue
?
'0.06em'
:
'0'
,
textTransform
:
focused
||
hasValue
?
'uppercase'
:
'none'
,
}
})
{
const
classes
=
'mt-2 w-full rounded-md border border-[var(--border)] bg-white px-3 py-3 text-[var(--text)] outline-none transition-colors placeholder:text-slate-400 focus:border-[var(--primary)]'
return
(
<
div
className=
"input-wrapper relative pb-1"
style=
{
{
borderBottom
:
`1px solid ${focused ? 'var(--color-primary)' : 'rgba(255,255,255,0.1)'}`
,
transition
:
'border-color 0.3s'
}
}
>
<
label
htmlFor=
{
id
}
style=
{
labelStyle
}
>
{
label
}
</
label
>
<
label
htmlFor=
{
id
}
className=
"block text-sm font-bold text-[var(--text)]"
>
{
label
}
{
multiline
?
(
<
textarea
id=
{
id
}
rows=
{
4
}
rows=
{
5
}
value=
{
value
}
onChange=
{
(
e
)
=>
onChange
(
e
.
target
.
value
)
}
onFocus=
{
()
=>
setFocused
(
true
)
}
onBlur=
{
()
=>
setFocused
(
false
)
}
style=
{
baseStyle
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
className=
{
classes
}
/>
)
:
(
<
input
id=
{
id
}
type=
{
type
}
value=
{
value
}
onChange=
{
(
e
)
=>
onChange
(
e
.
target
.
value
)
}
onFocus=
{
()
=>
setFocused
(
true
)
}
onBlur=
{
()
=>
setFocused
(
false
)
}
style=
{
baseStyle
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
className=
{
classes
}
/>
)
}
{
/* Animated sweep underline */
}
<
div
style=
{
{
position
:
'absolute'
,
bottom
:
-
1
,
left
:
0
,
height
:
'2px'
,
width
:
focused
?
'100%'
:
'0'
,
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
,
transition
:
'width 0.4s cubic-bezier(0.4,0,0.2,1)'
,
borderRadius
:
'999px'
,
}
}
/>
</
div
>
</
label
>
)
}
// ─── Contact Section ──────────────────────────────────────────
export
default
function
Contact
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
[
name
,
setName
]
=
useState
(
''
)
const
[
email
,
setEmail
]
=
useState
(
''
)
const
[
message
,
setMessage
]
=
useState
(
''
)
const
handleSubmit
=
(
e
:
React
.
FormEvent
)
=>
{
e
.
preventDefault
()
const
handleSubmit
=
(
e
vent
:
FormEvent
)
=>
{
e
vent
.
preventDefault
()
const
subject
=
encodeURIComponent
(
`Portfolio Contact from
${
name
}
`
)
const
body
=
encodeURIComponent
(
`Hi Dat,\n\n
${
message
}
\n\nFrom:
${
name
}
\nEmail:
${
email
}
`
)
window
.
location
.
href
=
`mailto:
${
personalInfo
.
email
}
?subject=
${
subject
}
&body=
${
body
}
`
}
const
socialLinks
=
[
{
id
:
'contact-github'
,
icon
:
GithubIcon
,
label
:
'GitHub'
,
href
:
personalInfo
.
githubUrl
,
value
:
`github.com/
${
personalInfo
.
github
}
`
,
},
{
id
:
'contact-email'
,
icon
:
Mail
,
label
:
'Email'
,
href
:
`mailto:
${
personalInfo
.
email
}
`
,
value
:
personalInfo
.
email
,
},
{
id
:
'contact-linkedin'
,
icon
:
LinkedinIcon
,
label
:
'LinkedIn'
,
href
:
'https://linkedin.com'
,
value
:
'linkedin.com/in/datld'
,
},
]
return
(
<
section
id=
"contact"
ref=
{
sectionRef
}
className=
"py-24"
aria
-
label=
"Contact section"
>
<
section
id=
"contact"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Contact section"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
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
"
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
05. Contact
</
p
>
<
h2
className=
"section-title"
>
Let's talk
</
h2
>
<
div
className=
"mt-3 h-1 w-16 rounded-full"
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
/>
<
p
className=
"section-kicker"
>
Contact
</
p
>
<
h2
className=
"section-title"
>
Let
'
s talk.
</
h2
>
</
motion
.
div
>
{
/* Split Layout */
}
<
div
className=
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-start"
>
{
/* Left — Message + Links */
}
<
div
className=
"grid gap-6 lg:grid-cols-[360px_1fr]"
>
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
-
30
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.7
,
delay
:
0.2
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
,
delay
:
0.08
}
}
className=
"card p-6"
>
<
p
className=
"text-xl sm:text-2xl font-semibold mb-4 leading-snug"
style=
{
{
fontFamily
:
'var(--font-heading)'
,
color
:
'var(--color-text)'
}
}
>
If you're looking for a backend developer who takes system design seriously,
{
' '
}
<
span
className=
"gradient-text"
>
I'd love to connect.
</
span
>
</
p
>
<
p
className=
"text-sm mb-10"
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
Open to internships, part-time roles, and collaboration.
<
p
className=
"text-base leading-8 text-[var(--muted-2)]"
>
Open to internships, junior backend roles, and project collaboration.
</
p
>
<
div
className=
"space-y-5"
>
{
socialLinks
.
map
((
link
,
i
)
=>
(
<
motion
.
a
key=
{
link
.
id
}
id=
{
link
.
id
}
href=
{
link
.
href
}
target=
{
link
.
href
.
startsWith
(
'http'
)
?
'_blank'
:
undefined
}
rel=
{
link
.
href
.
startsWith
(
'http'
)
?
'noopener noreferrer'
:
undefined
}
initial=
{
{
opacity
:
0
,
x
:
-
16
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.5
,
delay
:
0.3
+
i
*
0.1
}
}
className=
"flex items-center gap-4 p-4 rounded-xl transition-all duration-200 group"
style=
{
{
background
:
'var(--color-surface)'
,
border
:
'1px solid var(--color-border-subtle)'
,
}
}
whileHover=
{
{
borderColor
:
'rgba(249,115,22,0.4)'
,
boxShadow
:
'0 0 16px rgba(249,115,22,0.15)'
,
x
:
4
,
}
}
>
<
div
className=
"flex items-center justify-center w-10 h-10 rounded-lg shrink-0"
style=
{
{
background
:
'rgba(249,115,22,0.1)'
,
border
:
'1px solid rgba(249,115,22,0.2)'
,
color
:
'var(--color-primary)'
,
}
}
>
<
link
.
icon
size=
{
18
}
/>
</
div
>
<
div
>
<
div
className=
"text-xs uppercase tracking-widest mb-0.5"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
{
link
.
label
}
</
div
>
<
div
className=
"text-sm font-medium group-hover:text-[var(--color-primary)] transition-colors"
style=
{
{
color
:
'var(--color-text)'
}
}
>
{
link
.
value
}
</
div
>
</
div
>
</
motion
.
a
>
))
}
<
div
className=
"mt-6 space-y-3"
>
<
a
href=
{
`mailto:${personalInfo.email}`
}
className=
"btn-secondary w-full justify-start"
>
<
Mail
size=
{
18
}
/>
{
personalInfo
.
email
}
</
a
>
<
a
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
rel=
"noopener noreferrer"
className=
"btn-secondary w-full justify-start"
>
<
GithubIcon
size=
{
18
}
/>
github.com/
{
personalInfo
.
github
}
</
a
>
</
div
>
</
motion
.
div
>
{
/* Right — Form */
}
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
30
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.7
,
delay
:
0.3
}
}
<
motion
.
form
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
,
delay
:
0.16
}
}
onSubmit=
{
handleSubmit
}
className=
"card space-y-5 p-6"
aria
-
label=
"Contact form"
>
<
form
onSubmit=
{
handleSubmit
}
className=
"rounded-2xl p-8 space-y-8"
style=
{
{
background
:
'var(--color-surface)'
,
border
:
'1px solid var(--color-border-subtle)'
,
}
}
aria
-
label=
"Contact form"
>
<
AnimatedInput
id=
"contact-name"
label=
"Your Name"
value=
{
name
}
onChange=
{
setName
}
/>
<
AnimatedInput
id=
"contact-email"
label=
"Email Address"
type=
"email"
value=
{
email
}
onChange=
{
setEmail
}
/>
<
AnimatedInput
id=
"contact-message"
label=
"Message"
value=
{
message
}
onChange=
{
setMessage
}
multiline
/>
<
button
id=
"contact-submit"
type=
"submit"
className=
"w-full flex items-center justify-center gap-2 py-4 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]"
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
color
:
'#0A0F1E'
,
boxShadow
:
'0 0 20px rgba(249,115,22,0.25)'
,
fontFamily
:
'var(--font-heading)'
,
}
}
>
<
Send
size=
{
16
}
/>
Send Message
</
button
>
</
form
>
</
motion
.
div
>
<
Field
id=
"contact-name"
label=
"Name"
value=
{
name
}
onChange=
{
setName
}
/>
<
Field
id=
"contact-email"
label=
"Email"
type=
"email"
value=
{
email
}
onChange=
{
setEmail
}
/>
<
Field
id=
"contact-message"
label=
"Message"
value=
{
message
}
onChange=
{
setMessage
}
multiline
/>
<
button
type=
"submit"
className=
"btn-primary"
>
<
Send
size=
{
18
}
/>
Send Message
</
button
>
</
motion
.
form
>
</
div
>
</
div
>
</
section
>
...
...
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
'use client'
import
{
useEffect
,
useRef
,
useState
}
from
'
react'
import
{
Mail
,
MapPin
}
from
'lucide-
react'
import
{
motion
,
useReducedMotion
}
from
'framer-motion'
import
{
Mail
,
ChevronDown
}
from
'lucide-react'
import
{
useTypingEffect
}
from
'@/hooks/useTypingEffect'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
import
{
personalInfo
}
from
'@/lib/data'
// ─── SVG Brand Icons ──────────────────────────────────────────
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
Hero
()
{
const
prefersReduced
=
useReducedMotion
()
const
variants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
const
{
displayText
,
phase
}
=
useTypingEffect
()
return
(
<
section
id=
"hero"
className=
"relative flex items-center min-h-screen overflow-hidden"
aria
-
label=
"Hero section"
>
{
/* Animated Dot-Grid Background */
}
<
div
className=
"hero-bg pointer-events-none"
aria
-
hidden=
"true"
/>
{
/* Content */
}
<
div
className=
"section-container relative z-10 w-full pt-24 pb-16"
>
<
div
className=
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-center"
>
{
/* Left — Staggered entrance */
}
<
div
className=
"max-w-xl"
>
{
/* i=0 Eyebrow */
}
<
motion
.
p
variants=
{
variants
}
custom=
{
0
}
initial=
"hidden"
animate=
"visible"
className=
"muted-eyebrow mb-3"
>
Hey, I
'
m
</
motion
.
p
>
<
section
id=
"hero"
className=
"section-shell pt-32 sm:pt-36"
aria
-
label=
"Hero section"
>
<
div
className=
"section-container"
>
<
div
className=
"grid gap-8 lg:grid-cols-[1fr_360px] lg:items-end"
>
<
motion
.
div
variants=
{
variants
}
custom=
{
0
}
initial=
"hidden"
animate=
"visible"
className=
"max-w-3xl"
>
<
div
className=
"pill px-3 py-2 mb-5"
>
<
span
className=
"h-2 w-2 rounded-full bg-[var(--accent)]"
/>
Open to internship and junior backend roles
</
div
>
{
/* i=1 Name */
}
<
motion
.
h1
variants=
{
variants
}
custom=
{
1
}
initial=
"hidden"
animate=
"visible"
className=
"gradient-text hero-name mb-4 leading-none"
>
<
h1
className=
"text-[clamp(2.55rem,8vw,5.1rem)] tracking-normal"
>
{
personalInfo
.
name
}
</
motion
.
h1
>
</
h1
>
{
/* i=2 Typing role */
}
<
motion
.
div
variants=
{
variants
}
custom=
{
2
}
initial=
"hidden"
animate=
"visible"
className=
"flex items-center gap-1 mb-6 h-9"
>
<
span
className=
"text-xl sm:text-2xl font-semibold"
style=
{
{
color
:
'var(--color-muted2)'
,
fontFamily
:
'var(--font-heading)'
}
}
>
{
displayText
}
</
span
>
{
/* Blinking cursor */
}
{
!
prefersReduced
&&
(
<
span
className=
"typing-cursor inline-block w-[2px] h-7 rounded-sm ml-0.5"
style=
{
{
background
:
'var(--primary)'
}
}
aria
-
hidden=
"true"
/>
)
}
</
motion
.
div
>
<
p
className=
"mt-4 text-xl font-semibold text-[var(--primary)] sm:text-2xl"
>
{
personalInfo
.
role
}
</
p
>
{
/* i=3 Short bio */
}
<
motion
.
p
variants=
{
variants
}
custom=
{
3
}
initial=
"hidden"
animate=
"visible"
className=
"text-base sm:text-lg mb-8 max-w-md"
style=
{
{
color
:
'var(--color-muted2)'
,
lineHeight
:
1.7
}
}
>
{
personalInfo
.
shortBio
}
</
motion
.
p
>
<
p
className=
"mt-5 max-w-2xl text-base leading-8 text-[var(--muted-2)] sm:text-lg"
>
I build clean REST APIs, Spring Boot services, and database-backed systems with a focus on readability and steady growth.
</
p
>
{
/* i=4 CTA buttons */
}
<
motion
.
div
variants=
{
variants
}
custom=
{
4
}
initial=
"hidden"
animate=
"visible"
className=
"flex flex-wrap gap-4 mb-10"
>
<
a
href=
"#projects"
id=
"hero-view-projects"
onClick=
{
(
e
)
=>
{
e
.
preventDefault
()
document
.
getElementById
(
'projects'
)?.
scrollIntoView
({
behavior
:
'smooth'
})
}
}
className=
"inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-105 hover:shadow-lg"
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
color
:
'#0A0F1E'
,
boxShadow
:
'0 0 20px rgba(249,115,22,0.3)'
,
fontFamily
:
'var(--font-heading)'
,
}
}
>
<
div
className=
"mt-8 flex flex-col gap-3 sm:flex-row"
>
<
a
href=
"#projects"
className=
"btn-primary"
>
View Projects
</
a
>
<
a
href=
{
`mailto:${personalInfo.email}`
}
id=
"hero-contact-link"
className=
"inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm border transition-all duration-300 hover:scale-105"
style=
{
{
borderColor
:
'var(--primary)'
,
color
:
'var(--primary)'
,
background
:
'transparent'
,
fontFamily
:
'var(--font-heading)'
,
}
}
onMouseEnter=
{
(
e
)
=>
{
e
.
currentTarget
.
style
.
background
=
'rgba(249,115,22,0.1)'
}
}
onMouseLeave=
{
(
e
)
=>
{
e
.
currentTarget
.
style
.
background
=
'transparent'
}
}
>
Get in Touch
<
a
href=
{
`mailto:${personalInfo.email}`
}
className=
"btn-secondary"
>
<
Mail
size=
{
18
}
/>
Contact Me
</
a
>
</
motion
.
div
>
</
div
>
</
motion
.
div
>
{
/* i=5 Social icons + scroll arrow */
}
<
motion
.
div
variants=
{
variants
}
custom=
{
5
}
initial=
"hidden"
animate=
"visible"
className=
"flex items-center gap-4"
>
<
motion
.
aside
variants=
{
variants
}
custom=
{
1
}
initial=
"hidden"
animate=
"visible"
className=
"card p-5"
aria
-
label=
"Profile summary"
>
<
div
className=
"mb-5 flex h-16 w-16 items-center justify-center rounded-lg bg-[var(--surface-2)] text-xl font-bold text-[var(--primary)]"
>
{
personalInfo
.
initials
}
</
div
>
<
dl
className=
"space-y-4 text-sm"
>
<
div
>
<
dt
className=
"font-semibold text-[var(--text)]"
>
Focus
</
dt
>
<
dd
className=
"mt-1 text-[var(--muted)]"
>
Java, Spring Boot, REST APIs
</
dd
>
</
div
>
<
div
>
<
dt
className=
"font-semibold text-[var(--text)]"
>
Education
</
dt
>
<
dd
className=
"mt-1 text-[var(--muted)]"
>
Software Engineering at PTIT HCMC
</
dd
>
</
div
>
<
div
>
<
dt
className=
"font-semibold text-[var(--text)]"
>
Location
</
dt
>
<
dd
className=
"mt-1 flex items-center gap-2 text-[var(--muted)]"
>
<
MapPin
size=
{
16
}
/>
Ho Chi Minh City
</
dd
>
</
div
>
</
dl
>
<
div
className=
"mt-6 flex gap-3 border-t border-[var(--border)] pt-5"
>
<
a
href=
{
personalInfo
.
githubUrl
}
id=
"hero-github-link"
target=
"_blank"
rel=
"noopener noreferrer"
className=
"flex items-center gap-2 text-sm transition-all duration-200 group"
style=
{
{
color
:
'var(--color-muted)'
}
}
className=
"btn-secondary min-h-10 flex-1 px-3 py-2"
aria
-
label=
"GitHub profile"
>
<
GithubIcon
size=
{
18
}
/>
<
span
className=
"group-hover:text-[var(--color-text)] transition-colors"
>
github.com/
{
personalInfo
.
github
}
</
span
>
<
GithubIcon
size=
{
17
}
/>
GitHub
</
a
>
<
span
style=
{
{
color
:
'var(--color-border-subtle)'
}
}
>
·
</
span
>
<
a
href=
{
`mailto:${personalInfo.email}`
}
id=
"hero-email-link"
className=
"flex items-center gap-2 text-sm transition-all duration-200 group"
style=
{
{
color
:
'var(--color-muted)'
}
}
aria
-
label=
"Email contact"
className=
"btn-secondary min-h-10 flex-1 px-3 py-2"
aria
-
label=
"Email"
>
<
Mail
size=
{
18
}
className=
"group-hover:text-[var(--primary)] transition-colors"
/>
<
span
className=
"group-hover:text-[var(--color-text)] transition-colors"
>
{
personalInfo
.
email
}
</
span
>
<
Mail
size=
{
17
}
/>
Email
</
a
>
</
motion
.
div
>
</
div
>
{
/* Right — Animated Terminal */
}
<
motion
.
div
initial=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
40
}
}
animate=
{
prefersReduced
?
{
opacity
:
1
}
:
{
opacity
:
1
,
x
:
0
}
}
transition=
{
{
duration
:
0.8
,
delay
:
0.9
,
ease
:
'easeOut'
}
}
className=
"hidden lg:flex justify-end"
>
<
div
className=
"terminal-float"
>
<
TerminalWidget
/>
</
div
>
</
motion
.
div
>
</
motion
.
aside
>
</
div
>
</
div
>
{
/* Scroll-down Arrow */
}
<
motion
.
div
initial=
{
{
opacity
:
0
}
}
animate=
{
{
opacity
:
1
}
}
transition=
{
{
delay
:
1.6
,
duration
:
0.6
}
}
className=
"absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-1"
>
<
span
className=
"text-xs tracking-widest uppercase"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
scroll
</
span
>
<
ChevronDown
size=
{
20
}
className=
"scroll-bounce"
style=
{
{
color
:
'var(--primary)'
}
}
/>
</
motion
.
div
>
</
section
>
)
}
// ─── Terminal Widget — lines appear one by one ────────────────
function
TerminalWidget
()
{
const
lines
=
[
{
prefix
:
'$ '
,
text
:
'java -jar portfolio.jar'
,
color
:
'var(--color-text)'
},
{
prefix
:
'> '
,
text
:
'Initializing Spring context...'
,
color
:
'var(--color-muted)'
},
{
prefix
:
'> '
,
text
:
'Loading beans: 42 definitions'
,
color
:
'var(--color-muted)'
},
{
prefix
:
'> '
,
text
:
'Server started on :8080'
,
color
:
'var(--color-muted)'
},
{
prefix
:
'> '
,
text
:
'Status: UP ✓'
,
color
:
'#22c55e'
},
]
const
[
visibleCount
,
setVisibleCount
]
=
useState
(
0
)
const
timerRef
=
useRef
<
ReturnType
<
typeof
setTimeout
>
|
null
>
(
null
)
useEffect
(()
=>
{
function
showNext
(
idx
:
number
)
{
if
(
idx
>=
lines
.
length
)
return
timerRef
.
current
=
setTimeout
(()
=>
{
setVisibleCount
(
idx
+
1
)
showNext
(
idx
+
1
)
},
600
)
}
// start after a short delay so the widget entrance animation completes
timerRef
.
current
=
setTimeout
(()
=>
showNext
(
0
),
300
)
return
()
=>
{
if
(
timerRef
.
current
)
clearTimeout
(
timerRef
.
current
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[])
const
allDone
=
visibleCount
>=
lines
.
length
return
(
<
div
className=
"rounded-xl overflow-hidden shadow-2xl w-80"
style=
{
{
border
:
'1px solid rgba(249,115,22,0.25)'
,
boxShadow
:
'0 0 40px rgba(249,115,22,0.1)'
}
}
>
{
/* Title bar */
}
<
div
className=
"flex items-center gap-2 px-4 py-3"
style=
{
{
background
:
'#1A1A2E'
,
borderBottom
:
'1px solid rgba(255,255,255,0.06)'
}
}
>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#FF5F57'
}
}
/>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#FEBC2E'
}
}
/>
<
span
className=
"w-3 h-3 rounded-full"
style=
{
{
background
:
'#28C840'
}
}
/>
<
span
className=
"ml-2 text-xs"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
terminal
</
span
>
</
div
>
{
/* Body */
}
<
div
className=
"px-4 py-4 space-y-2"
style=
{
{
background
:
'#0D1117'
,
fontFamily
:
'var(--font-mono)'
,
fontSize
:
'0.78rem'
,
minHeight
:
'8rem'
}
}
>
{
lines
.
slice
(
0
,
visibleCount
).
map
((
line
,
i
)
=>
(
<
motion
.
div
key=
{
i
}
initial=
{
{
opacity
:
0
,
x
:
-
8
}
}
animate=
{
{
opacity
:
1
,
x
:
0
}
}
transition=
{
{
duration
:
0.25
}
}
className=
"flex items-start gap-1"
>
<
span
style=
{
{
color
:
'var(--primary)'
}
}
>
{
line
.
prefix
}
</
span
>
<
span
style=
{
{
color
:
line
.
color
}
}
>
{
line
.
text
}
</
span
>
</
motion
.
div
>
))
}
{
/* Blinking cursor — shown after all lines or while typing */
}
<
div
className=
"flex items-center gap-1"
>
<
span
style=
{
{
color
:
'var(--primary)'
}
}
>
$
</
span
>
{
allDone
&&
(
<
span
className=
"typing-cursor inline-block w-2 h-4 rounded-sm"
style=
{
{
background
:
'var(--primary)'
}
}
aria
-
hidden=
"true"
/>
)
}
</
div
>
</
div
>
</
div
>
)
}
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