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'
;
@import
'tailwindcss'
;
/* ============================================================
CSS Custom Properties — Design Tokens
============================================================ */
:root
{
:root
{
--bg
:
#0A0F1E
;
--bg
:
#f7f8fb
;
--surface
:
#111827
;
--surface
:
#ffffff
;
--surface-2
:
#1A2236
;
--surface-2
:
#eef2f7
;
--border
:
rgba
(
249
,
115
,
22
,
0.15
);
--border
:
#d9e0ea
;
--primary
:
#F97316
;
--primary
:
#2563eb
;
--accent
:
#FB923C
;
--primary-dark
:
#1d4ed8
;
--text
:
#F1F5F9
;
--accent
:
#0f766e
;
--muted
:
#64748B
;
--text
:
#111827
;
--gradient
:
linear-gradient
(
135deg
,
#F97316
,
#FB923C
);
--muted
:
#5b6472
;
--muted-2
:
#374151
;
/* Typography */
--font-heading
:
var
(
--font-space
,
var
(
--font-space-grotesk
)),
'Space Grotesk'
,
system-ui
,
sans-serif
;
--font-heading
:
var
(
--font-space
,
var
(
--font-space-grotesk
)),
system-ui
,
sans-serif
;
--font-body
:
var
(
--font-inter
),
'Inter'
,
system-ui
,
sans-serif
;
--font-body
:
var
(
--font-inter
),
system-ui
,
sans-serif
;
--font-mono
:
var
(
--font-mono
,
var
(
--font-jetbrains-mono
)),
'JetBrains Mono'
,
'Fira Code'
,
monospace
;
--font-mono
:
var
(
--font-mono
,
var
(
--font-jetbrains-mono
)),
ui-monospace
,
monospace
;
/* Spacing & Radius */
--color-bg
:
var
(
--bg
);
--radius-sm
:
6px
;
--color-surface
:
var
(
--surface
);
--radius-md
:
12px
;
--color-surface2
:
var
(
--surface-2
);
--radius-lg
:
16px
;
--color-primary
:
var
(
--primary
);
--radius-xl
:
24px
;
--color-accent
:
var
(
--accent
);
--color-text
:
var
(
--text
);
/* Aliases for backwards compatibility */
--color-muted
:
var
(
--muted
);
--color-bg
:
var
(
--bg
);
--color-muted2
:
var
(
--muted-2
);
--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
);
--color-border-subtle
:
var
(
--border
);
--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
{
@theme
inline
{
--color-bg
:
var
(
--color-bg
);
--color-bg
:
var
(
--color-bg
);
--color-surface
:
var
(
--color-surface
);
--color-surface
:
var
(
--color-surface
);
--color-surface2
:
var
(
--color-surface2
);
--color-surface2
:
var
(
--color-surface2
);
--color-primary
:
var
(
--color-primary
);
--color-primary
:
var
(
--color-primary
);
--color-accent
:
var
(
--color-accent
);
--color-accent
:
var
(
--color-accent
);
--color-text
:
var
(
--color-text
);
--color-text
:
var
(
--color-text
);
--color-muted
:
var
(
--color-muted
);
--color-muted
:
var
(
--color-muted
);
--color-muted2
:
var
(
--color-muted2
);
--color-muted2
:
var
(
--color-muted2
);
--font-sans
:
var
(
--font-body
);
--font-sans
:
var
(
--font-body
);
--font-mono
:
var
(
--font-mono
);
--font-mono
:
var
(
--font-mono
);
}
}
/* ============================================================
Base Reset & Global Styles
============================================================ */
*,
*,
*
::before
,
*
::before
,
*
::after
{
*
::after
{
box-sizing
:
border-box
;
box-sizing
:
border-box
;
margin
:
0
;
padding
:
0
;
}
}
html
{
html
{
scroll-behavior
:
smooth
;
scroll-behavior
:
smooth
;
scroll-padding-top
:
88px
;
-webkit-font-smoothing
:
antialiased
;
-webkit-font-smoothing
:
antialiased
;
-moz-osx-font-smoothing
:
grayscale
;
-moz-osx-font-smoothing
:
grayscale
;
}
}
body
{
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
);
color
:
var
(
--text
);
font-family
:
var
(
--font-body
);
font-family
:
var
(
--font-body
);
font-size
:
1rem
;
font-size
:
1rem
;
line-height
:
1.75
;
line-height
:
1.65
;
min-height
:
100vh
;
overflow-x
:
hidden
;
overflow-x
:
hidden
;
}
}
/* ============================================================
h1
,
Typography Defaults
h2
,
============================================================ */
h3
,
h
1
,
h2
,
h3
,
h4
,
h5
,
h6
{
h
4
,
font-family
:
var
(
--font-heading
);
h5
,
font-weight
:
700
;
h6
{
line-height
:
1.2
;
margin
:
0
;
color
:
var
(
--text
);
color
:
var
(
--text
);
}
.hero-name
{
font-size
:
clamp
(
2.8rem
,
7vw
,
5.5rem
);
font-weight
:
700
;
font-family
:
var
(
--font-heading
);
font-family
:
var
(
--font-heading
);
}
font-weight
:
700
;
line-height
:
1.12
;
.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
);
}
}
p
{
p
{
color
:
var
(
--color-muted2
);
margin
:
0
;
line-height
:
1.7
;
color
:
var
(
--muted-2
);
}
code
,
pre
,
kbd
{
font-family
:
var
(
--font-mono
);
}
}
a
{
a
{
...
@@ -125,315 +89,149 @@ a {
...
@@ -125,315 +89,149 @@ a {
text-decoration
:
none
;
text-decoration
:
none
;
}
}
/* ============================================================
button
,
Scrollbar Styling
input
,
============================================================ */
textarea
{
::-webkit-scrollbar
{
font
:
inherit
;
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
);
}
}
/* ============================================================
Selection
============================================================ */
::selection
{
::selection
{
background
:
rgba
(
249
,
115
,
22
,
0.3
);
background
:
rgba
(
37
,
99
,
235
,
0.16
);
color
:
var
(
--color-text
);
}
/* ============================================================
Hero Dot-Grid Animation
============================================================ */
@keyframes
gridPan
{
from
{
background-position
:
0
0
;
}
to
{
background-position
:
28px
28px
;
}
}
}
.hero-bg
{
::-webkit-scrollbar
{
position
:
absolute
;
width
:
8px
;
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
);
}
}
}
.terminal-float
{
::-webkit-scrollbar-track
{
animation
:
terminalFloat
4s
ease-in-out
infinite
;
background
:
var
(
--surface-2
)
;
}
}
/* ============================================================
::-webkit-scrollbar-thumb
{
Blinking Cursor
background
:
#b8c2d2
;
============================================================ */
border-radius
:
999px
;
@keyframes
blink
{
0
%,
100
%
{
opacity
:
1
;
}
50
%
{
opacity
:
0
;
}
}
}
.cursor-blink
{
.section-container
{
animation
:
blink
1s
step-end
infinite
;
width
:
min
(
100%
-
2rem
,
1120px
);
margin-inline
:
auto
;
}
}
/* Typing cursor — orange, 0.7s step-end */
.section-shell
{
.typing-cursor
{
padding-block
:
clamp
(
3.5rem
,
7vw
,
5.5rem
);
animation
:
blink
0.7s
step-end
infinite
;
}
}
/* ============================================================
.section-heading
{
Navbar — Scrolled State
margin-bottom
:
2rem
;
============================================================ */
nav
{
transition
:
background-color
0.3s
ease
,
backdrop-filter
0.3s
ease
;
}
}
nav
.scrolled
{
.section-kicker
{
background
:
rgba
(
10
,
15
,
30
,
0.92
)
!important
;
margin-bottom
:
0.6rem
;
backdrop-filter
:
blur
(
16px
)
!important
;
color
:
var
(
--primary
);
-webkit-backdrop-filter
:
blur
(
16px
)
!important
;
font-size
:
0.78rem
;
font-weight
:
700
;
letter-spacing
:
0.08em
;
text-transform
:
uppercase
;
}
}
/* ============================================================
.section-title
{
Scroll-down Bounce
max-width
:
680px
;
============================================================ */
font-size
:
clamp
(
1.8rem
,
3.5vw
,
2.8rem
);
@keyframes
scrollBounce
{
letter-spacing
:
0
;
0
%,
100
%
{
transform
:
translateY
(
0
);
}
50
%
{
transform
:
translateY
(
8px
);
}
}
}
.scroll-bounce
{
.card
{
animation
:
scrollBounce
1.5s
ease-in-out
infinite
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
;
background
:
var
(
--surface
);
box-shadow
:
0
14px
32px
rgba
(
15
,
23
,
42
,
0.06
);
}
}
/* ============================================================
.pill
{
Section Divider
display
:
inline-flex
;
============================================================ */
align-items
:
center
;
.section-divider
{
gap
:
0.45rem
;
border
:
none
;
border
:
1px
solid
var
(
--border
);
height
:
1px
;
border-radius
:
999px
;
background
:
linear-gradient
(
90deg
,
transparent
,
rgba
(
249
,
115
,
22
,
0.25
)
30%
,
rgba
(
249
,
115
,
22
,
0.25
)
70%
,
transparent
);
background
:
#fff
;
margin
:
0
;
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
;
}
}
/* ============================================================
.btn-primary
{
Orange gradient text utility
background
:
var
(
--primary
);
============================================================ */
color
:
#fff
;
.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
;
}
}
@keyframes
shimm
er
{
.btn-primary
:hov
er
{
0
%,
100
%
{
background-position
:
0%
50%
;
}
background
:
var
(
--primary-dark
);
50
%
{
background-position
:
100%
50%
;
}
transform
:
translateY
(
-1px
);
}
}
/* ============================================================
.btn-secondary
{
Glass Card utility
============================================================ */
.glass-card
{
background
:
rgba
(
17
,
24
,
39
,
0.7
);
backdrop-filter
:
blur
(
12px
);
-webkit-backdrop-filter
:
blur
(
12px
);
border
:
1px
solid
var
(
--border
);
border
:
1px
solid
var
(
--border
);
transition
:
border-color
0.3s
ease
,
box-shadow
0.3s
ease
,
transform
0.3s
ease
;
background
:
#fff
;
}
color
:
var
(
--text
);
.glass-card
:hover
{
border-color
:
var
(
--primary
);
box-shadow
:
var
(
--glow-primary
);
}
}
.project-card
,
.skill-card
{
.btn-secondary
:hover
{
background
:
rgba
(
17
,
24
,
39
,
0.8
);
border-color
:
#b7c2d4
;
border
:
1px
solid
rgba
(
249
,
115
,
22
,
0.15
);
transform
:
translateY
(
-1px
);
backdrop-filter
:
blur
(
12px
);
border-radius
:
12px
;
transition
:
border-color
0.3s
,
box-shadow
0.3s
;
}
}
.project-card
:hover
,
.skill-card
:hover
{
.tag
{
border-color
:
rgba
(
249
,
115
,
22
,
0.5
);
display
:
inline-flex
;
box-shadow
:
0
0
24px
rgba
(
249
,
115
,
22
,
0.1
);
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
{
.progress-track
{
background
:
var
(
--surface-2
);
height
:
6px
;
height
:
6px
;
border-radius
:
3px
;
position
:
relative
;
overflow
:
hidden
;
overflow
:
hidden
;
border-radius
:
999px
;
background
:
#dbe3ee
;
}
}
.progress-fill
{
.progress-fill
{
background
:
linear-gradient
(
90deg
,
#F97316
,
#FB923C
);
border-radius
:
3px
;
height
:
100%
;
height
:
100%
;
border-radius
:
inherit
;
background
:
var
(
--primary
);
}
}
/* ============================================================
*
:focus-visible
{
Section Base
outline
:
3px
solid
rgba
(
37
,
99
,
235
,
0.35
);
============================================================ */
outline-offset
:
3px
;
.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
;
}
}
}
@media
(
max-width
:
640px
)
{
@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
{
.section-container
{
padding-left
:
1rem
;
width
:
min
(
100%
-
1.25rem
,
1120px
);
padding-right
:
1rem
;
}
}
}
/* ============================================================
Accessibility & Polish
============================================================ */
html
{
scroll-behavior
:
smooth
;
scroll-padding-top
:
80px
;
}
*
:focus-visible
{
.btn-primary
,
outline
:
2px
solid
#F97316
!important
;
.btn-secondary
{
outline-offset
:
2px
!important
;
width
:
100%
;
}
}
}
app/not-found.tsx
View file @
3eb5b4e1
...
@@ -2,35 +2,17 @@ import Link from 'next/link'
...
@@ -2,35 +2,17 @@ import Link from 'next/link'
export
default
function
NotFound
()
{
export
default
function
NotFound
()
{
return
(
return
(
<
div
<
div
className=
"flex min-h-screen flex-col items-center justify-center bg-[var(--bg)] p-6 text-center"
>
className=
"min-h-screen flex flex-col items-center justify-center p-6 text-center"
<
h1
className=
"mb-4 text-7xl font-extrabold text-[var(--primary)] sm:text-8xl"
>
style=
{
{
background
:
'var(--bg)'
}
}
>
<
h1
className=
"text-8xl font-extrabold mb-4 gradient-text"
style=
{
{
fontFamily
:
'var(--font-heading)'
}
}
>
404
404
</
h1
>
</
h1
>
<
p
<
p
className=
"mb-2 text-2xl font-semibold text-[var(--text)]"
>
className=
"text-2xl font-semibold mb-2"
style=
{
{
color
:
'var(--color-text)'
,
fontFamily
:
'var(--font-heading)'
}
}
>
Page not found.
Page not found.
</
p
>
</
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.
Sorry, the page you are looking for does not exist or has been moved.
</
p
>
</
p
>
<
Link
<
Link
href=
"/"
className=
"btn-primary"
>
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)'
,
}
}
>
Back to Home
Back to Home
</
Link
>
</
Link
>
</
div
>
</
div
>
...
...
app/page.tsx
View file @
3eb5b4e1
...
@@ -14,17 +14,12 @@ export default function Home() {
...
@@ -14,17 +14,12 @@ export default function Home() {
<>
<>
<
TopProgressBar
/>
<
TopProgressBar
/>
<
Navbar
/>
<
Navbar
/>
<
main
id=
"main-content"
>
<
main
id=
"main-content"
className=
"overflow-hidden"
>
<
Hero
/>
<
Hero
/>
<
hr
className=
"section-divider"
/>
<
About
/>
<
About
/>
<
hr
className=
"section-divider"
/>
<
Skills
/>
<
Skills
/>
<
hr
className=
"section-divider"
/>
<
Projects
/>
<
Projects
/>
<
hr
className=
"section-divider"
/>
<
Experience
/>
<
Experience
/>
<
hr
className=
"section-divider"
/>
<
Contact
/>
<
Contact
/>
</
main
>
</
main
>
<
Footer
/>
<
Footer
/>
...
...
components/About.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
{
useEffect
,
useRef
,
useState
}
from
'react'
import
{
motion
,
useInView
}
from
'framer-motion'
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
()
{
export
default
function
About
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
statsRef
=
useRef
<
HTMLDivElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-100px'
})
const
statsInView
=
useInView
(
statsRef
,
{
once
:
true
,
margin
:
'-50px'
})
const
bioLines
=
personalInfo
.
bio
.
split
(
'
\
n
\
n'
)
const
bioLines
=
personalInfo
.
bio
.
split
(
'
\
n
\
n'
)
return
(
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"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
}
}
transition=
{
{
duration
:
0.
45
}
}
className=
"
mb-16
"
className=
"
section-heading
"
>
>
<
p
<
p
className=
"section-kicker"
>
About
</
p
>
className=
"muted-eyebrow mb-3"
<
h2
className=
"section-title"
>
Backend-focused, practical, and always learning.
</
h2
>
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)'
}
}
/>
</
motion
.
div
>
</
motion
.
div
>
{
/* Two-column layout */
}
<
div
className=
"grid gap-6 lg:grid-cols-[1fr_360px]"
>
<
div
className=
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-start"
>
{
/* Left — Text */
}
<
motion
.
div
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
-
30
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
7
,
delay
:
0.2
}
}
transition=
{
{
duration
:
0.
45
,
delay
:
0.08
}
}
className=
"
space-y-5
"
className=
"
card p-6 sm:p-7
"
>
>
{
bioLines
.
map
((
line
,
i
)
=>
(
<
div
className=
"space-y-5"
>
<
p
key=
{
i
}
className=
"text-base leading-relaxed"
style=
{
{
color
:
'var(--color-muted2)'
}
}
>
{
bioLines
.
map
((
line
)
=>
(
{
line
}
<
p
key=
{
line
}
className=
"text-base leading-8 text-[var(--muted-2)]"
>
</
p
>
{
line
}
))
}
</
p
>
))
}
</
div
>
{
/* Tags */
}
<
div
className=
"mt-7 flex flex-wrap gap-2"
>
<
div
className=
"flex flex-wrap gap-2 pt-2"
>
{
[
'Java'
,
'Spring Boot'
,
'REST APIs'
,
'Microservices'
,
'Databases'
].
map
((
tag
)
=>
(
{
[
'OOP'
,
'Clean Code'
,
'API Design'
,
'System Design'
,
'Distributed Systems'
].
map
((
tag
)
=>
(
<
span
key=
{
tag
}
className=
"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)'
,
}
}
>
{
tag
}
{
tag
}
</
span
>
</
span
>
))
}
))
}
</
div
>
</
div
>
</
motion
.
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
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
stat
sInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
i
sInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
,
delay
:
0.1
}
}
transition=
{
{
duration
:
0.
45
,
delay
:
0.16
}
}
className=
"grid g
rid-cols-3 gap-4 sm:gap-6
"
className=
"grid g
ap-3
"
>
>
{
stats
.
map
((
stat
)
=>
(
{
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
>
</
motion
.
div
>
</
div
>
</
div
>
...
...
components/BackToTop.tsx
View file @
3eb5b4e1
...
@@ -27,11 +27,9 @@ export default function BackToTop() {
...
@@ -27,11 +27,9 @@ export default function BackToTop() {
animate=
{
{
opacity
:
1
,
scale
:
1
}
}
animate=
{
{
opacity
:
1
,
scale
:
1
}
}
exit=
{
{
opacity
:
0
,
scale
:
0.5
}
}
exit=
{
{
opacity
:
0
,
scale
:
0.5
}
}
onClick=
{
scrollToTop
}
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=
{
{
style=
{
{
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
boxShadow
:
'0 12px 30px rgba(15, 23, 42, 0.14)'
,
color
:
'#0A0F1E'
,
boxShadow
:
'0 0 20px rgba(249,115,22,0.4)'
,
}
}
}
}
aria
-
label=
"Back to top"
aria
-
label=
"Back to top"
>
>
...
...
components/Contact.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
{
useRef
,
useState
}
from
'react'
import
{
FormEvent
,
useRef
,
useState
}
from
'react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
Mail
,
Send
}
from
'lucide-react'
import
{
Mail
,
Send
}
from
'lucide-react'
import
{
motion
,
useInView
}
from
'framer-motion'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
personalInfo
}
from
'@/lib/data'
import
{
personalInfo
}
from
'@/lib/data'
// ─── SVG Brand Icons ──────────────────────────────────────────
function
Field
({
const
GithubIcon
=
({
size
=
18
}:
{
size
?:
number
})
=>
(
id
,
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"currentColor"
>
label
,
<
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"
/>
value
,
</
svg
>
onChange
,
)
type
=
'text'
,
multiline
=
false
,
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
{
id
:
string
id
:
string
label
:
string
label
:
string
type
?:
string
value
:
string
value
:
string
onChange
:
(
v
:
string
)
=>
void
onChange
:
(
value
:
string
)
=>
void
type
?:
string
multiline
?:
boolean
multiline
?:
boolean
}
})
{
const
classes
=
function
AnimatedInput
({
id
,
label
,
type
=
'text'
,
value
,
onChange
,
multiline
=
false
}:
InputProps
)
{
'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)]'
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'
,
}
return
(
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
}
className=
"block text-sm font-bold text-[var(--text)]"
>
<
label
htmlFor=
{
id
}
style=
{
labelStyle
}
>
{
label
}
</
label
>
{
label
}
{
multiline
?
(
{
multiline
?
(
<
textarea
<
textarea
id=
{
id
}
id=
{
id
}
rows=
{
4
}
rows=
{
5
}
value=
{
value
}
value=
{
value
}
onChange=
{
(
e
)
=>
onChange
(
e
.
target
.
value
)
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
onFocus=
{
()
=>
setFocused
(
true
)
}
className=
{
classes
}
onBlur=
{
()
=>
setFocused
(
false
)
}
style=
{
baseStyle
}
/>
/>
)
:
(
)
:
(
<
input
<
input
id=
{
id
}
id=
{
id
}
type=
{
type
}
type=
{
type
}
value=
{
value
}
value=
{
value
}
onChange=
{
(
e
)
=>
onChange
(
e
.
target
.
value
)
}
onChange=
{
(
event
)
=>
onChange
(
event
.
target
.
value
)
}
onFocus=
{
()
=>
setFocused
(
true
)
}
className=
{
classes
}
onBlur=
{
()
=>
setFocused
(
false
)
}
style=
{
baseStyle
}
/>
/>
)
}
)
}
{
/* Animated sweep underline */
}
</
label
>
<
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
>
)
)
}
}
// ─── Contact Section ──────────────────────────────────────────
export
default
function
Contact
()
{
export
default
function
Contact
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
[
name
,
setName
]
=
useState
(
''
)
const
[
name
,
setName
]
=
useState
(
''
)
const
[
email
,
setEmail
]
=
useState
(
''
)
const
[
email
,
setEmail
]
=
useState
(
''
)
const
[
message
,
setMessage
]
=
useState
(
''
)
const
[
message
,
setMessage
]
=
useState
(
''
)
const
handleSubmit
=
(
e
:
React
.
FormEvent
)
=>
{
const
handleSubmit
=
(
e
vent
:
FormEvent
)
=>
{
e
.
preventDefault
()
e
vent
.
preventDefault
()
const
subject
=
encodeURIComponent
(
`Portfolio Contact from
${
name
}
`
)
const
subject
=
encodeURIComponent
(
`Portfolio Contact from
${
name
}
`
)
const
body
=
encodeURIComponent
(
`Hi Dat,\n\n
${
message
}
\n\nFrom:
${
name
}
\nEmail:
${
email
}
`
)
const
body
=
encodeURIComponent
(
`Hi Dat,\n\n
${
message
}
\n\nFrom:
${
name
}
\nEmail:
${
email
}
`
)
window
.
location
.
href
=
`mailto:
${
personalInfo
.
email
}
?subject=
${
subject
}
&body=
${
body
}
`
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
(
return
(
<
section
<
section
id=
"contact"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Contact section"
>
id=
"contact"
ref=
{
sectionRef
}
className=
"py-24"
aria
-
label=
"Contact section"
>
<
div
className=
"section-container"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
}
}
transition=
{
{
duration
:
0.
45
}
}
className=
"
mb-16
"
className=
"
section-heading
"
>
>
<
p
<
p
className=
"section-kicker"
>
Contact
</
p
>
className=
"muted-eyebrow mb-3"
<
h2
className=
"section-title"
>
Let
'
s talk.
</
h2
>
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)'
}
}
/>
</
motion
.
div
>
</
motion
.
div
>
{
/* Split Layout */
}
<
div
className=
"grid gap-6 lg:grid-cols-[360px_1fr]"
>
<
div
className=
"grid grid-cols-1 lg:grid-cols-2 gap-12 items-start"
>
{
/* Left — Message + Links */
}
<
motion
.
div
<
motion
.
div
initial=
{
{
opacity
:
0
,
x
:
-
30
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.7
,
delay
:
0.2
}
}
transition=
{
{
duration
:
0.45
,
delay
:
0.08
}
}
className=
"card p-6"
>
>
<
p
<
p
className=
"text-base leading-8 text-[var(--muted-2)]"
>
className=
"text-xl sm:text-2xl font-semibold mb-4 leading-snug"
Open to internships, junior backend roles, and project collaboration.
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
>
</
p
>
<
div
className=
"space-y-5"
>
<
div
className=
"mt-6 space-y-3"
>
{
socialLinks
.
map
((
link
,
i
)
=>
(
<
a
href=
{
`mailto:${personalInfo.email}`
}
className=
"btn-secondary w-full justify-start"
>
<
motion
.
a
<
Mail
size=
{
18
}
/>
key=
{
link
.
id
}
{
personalInfo
.
email
}
id=
{
link
.
id
}
</
a
>
href=
{
link
.
href
}
<
a
target=
{
link
.
href
.
startsWith
(
'http'
)
?
'_blank'
:
undefined
}
href=
{
personalInfo
.
githubUrl
}
rel=
{
link
.
href
.
startsWith
(
'http'
)
?
'noopener noreferrer'
:
undefined
}
target=
"_blank"
initial=
{
{
opacity
:
0
,
x
:
-
16
}
}
rel=
"noopener noreferrer"
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
className=
"btn-secondary w-full justify-start"
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"
<
GithubIcon
size=
{
18
}
/>
style=
{
{
github.com/
{
personalInfo
.
github
}
background
:
'var(--color-surface)'
,
</
a
>
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
>
</
div
>
</
motion
.
div
>
</
motion
.
div
>
{
/* Right — Form */
}
<
motion
.
form
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
18
}
}
initial=
{
{
opacity
:
0
,
x
:
30
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
x
:
0
}
:
{}
}
transition=
{
{
duration
:
0.45
,
delay
:
0.16
}
}
transition=
{
{
duration
:
0.7
,
delay
:
0.3
}
}
onSubmit=
{
handleSubmit
}
className=
"card space-y-5 p-6"
aria
-
label=
"Contact form"
>
>
<
form
<
Field
id=
"contact-name"
label=
"Name"
value=
{
name
}
onChange=
{
setName
}
/>
onSubmit=
{
handleSubmit
}
<
Field
id=
"contact-email"
label=
"Email"
type=
"email"
value=
{
email
}
onChange=
{
setEmail
}
/>
className=
"rounded-2xl p-8 space-y-8"
<
Field
id=
"contact-message"
label=
"Message"
value=
{
message
}
onChange=
{
setMessage
}
multiline
/>
style=
{
{
background
:
'var(--color-surface)'
,
<
button
type=
"submit"
className=
"btn-primary"
>
border
:
'1px solid var(--color-border-subtle)'
,
<
Send
size=
{
18
}
/>
}
}
Send Message
aria
-
label=
"Contact form"
</
button
>
>
</
motion
.
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
>
</
div
>
</
div
>
</
div
>
</
div
>
</
section
>
</
section
>
...
...
components/Experience.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
{
useRef
}
from
'react'
import
{
useRef
}
from
'react'
import
{
motion
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
timelineEvents
,
type
TimelineEvent
}
from
'@/lib/data'
import
{
timelineEvents
}
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
>
)
}
export
default
function
Experience
()
{
export
default
function
Experience
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
const
prefersReduced
=
useReducedMotion
()
const
headingVariants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
return
(
return
(
<
section
<
section
id=
"experience"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Experience section"
>
id=
"experience"
ref=
{
sectionRef
}
className=
"py-24"
style=
{
{
background
:
'var(--color-surface)'
}
}
aria
-
label=
"Experience section"
>
<
div
className=
"section-container"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
<
motion
.
div
variants=
{
headingVariants
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
custom=
{
0
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
initial=
"hidden"
transition=
{
{
duration
:
0.45
}
}
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"section-heading"
className=
"mb-16"
>
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
<
p
className=
"section-kicker"
>
Experience
</
p
>
04. Experience
<
h2
className=
"section-title"
>
Recent milestones.
</
h2
>
</
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)'
}
}
/>
</
motion
.
div
>
</
motion
.
div
>
{
/* Timeline */
}
<
div
className=
"grid gap-3"
>
<
div
className=
"max-w-3xl"
>
{
timelineEvents
.
map
((
event
,
index
)
=>
(
{
timelineEvents
.
map
((
event
,
i
)
=>
(
<
motion
.
article
<
TimelineNode
key=
{
event
.
id
}
key=
{
event
.
id
}
event=
{
event
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
index=
{
i
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
isInView=
{
isInView
}
transition=
{
{
duration
:
0.38
,
delay
:
index
*
0.05
}
}
isLast=
{
i
===
timelineEvents
.
length
-
1
}
className=
"card grid gap-4 p-5 sm:grid-cols-[160px_1fr]"
prefersReduced=
{
prefersReduced
}
>
/>
<
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
>
</
div
>
</
div
>
...
...
components/Footer.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
navLinks
,
personalInfo
}
from
'@/lib/data'
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
()
{
export
default
function
Footer
()
{
const
handleNavClick
=
(
href
:
string
)
=>
{
const
handleNavClick
=
(
href
:
string
)
=>
{
const
id
=
href
.
slice
(
1
)
document
.
getElementById
(
href
.
slice
(
1
))?.
scrollIntoView
({
behavior
:
'smooth'
})
document
.
getElementById
(
id
)?.
scrollIntoView
({
behavior
:
'smooth'
})
}
}
return
(
return
(
<
footer
<
footer
className=
"border-t border-[var(--border)] bg-white py-8"
role=
"contentinfo"
>
className=
"py-10 border-t"
<
div
className=
"section-container flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"
>
style=
{
{
<
p
className=
"text-sm text-[var(--muted)]"
>
background
:
'var(--color-surface)'
,
Built with Next.js and Tailwind CSS. Ho Chi Minh City, 2026.
borderColor
:
'rgba(255,255,255,0.06)'
,
</
p
>
}
}
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
>
{
/* Nav Links */
}
<
nav
className=
"flex flex-wrap gap-3"
aria
-
label=
"Footer navigation"
>
<
nav
className=
"flex items-center flex-wrap gap-6 justify-center"
aria
-
label=
"Footer navigation"
>
{
navLinks
.
map
((
link
)
=>
(
{
navLinks
.
map
((
link
)
=>
(
<
button
<
button
key=
{
link
.
href
}
key=
{
link
.
href
}
type=
"button"
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
onClick=
{
()
=>
handleNavClick
(
link
.
href
)
}
className=
"text-xs uppercase tracking-widest transition-colors duration-200 hover:text-[var(--color-primary)]"
className=
"text-sm font-semibold text-[var(--muted)] hover:text-[var(--primary)]"
style=
{
{
color
:
'var(--color-muted)'
,
fontFamily
:
'var(--font-mono)'
}
}
>
>
{
link
.
label
}
{
link
.
label
}
</
button
>
</
button
>
))
}
))
}
</
nav
>
</
nav
>
{
/* GitHub Icon */
}
<
a
<
a
href=
{
personalInfo
.
githubUrl
}
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
target=
"_blank"
rel=
"noopener noreferrer"
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)]"
id=
"footer-github"
aria
-
label=
"GitHub profile"
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=
{
{
<
GithubIcon
size=
{
18
}
/>
background
:
'rgba(255,255,255,0.05)'
,
</
a
>
border
:
'1px solid rgba(255,255,255,0.08)'
,
color
:
'var(--color-muted)'
,
}
}
aria
-
label=
"GitHub profile"
>
<
GithubIcon
size=
{
18
}
/>
</
a
>
</
div
>
</
div
>
</
div
>
</
footer
>
</
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'
'use client'
import
{
useEffect
,
useRef
,
useState
}
from
'
react'
import
{
Mail
,
MapPin
}
from
'lucide-
react'
import
{
motion
,
useReducedMotion
}
from
'framer-motion'
import
{
motion
,
useReducedMotion
}
from
'framer-motion'
import
{
Mail
,
ChevronDown
}
from
'lucide-react'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
useTypingEffect
}
from
'@/hooks/useTypingEffect'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
import
{
personalInfo
}
from
'@/lib/data'
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
()
{
export
default
function
Hero
()
{
const
prefersReduced
=
useReducedMotion
()
const
prefersReduced
=
useReducedMotion
()
const
variants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
const
variants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
const
{
displayText
,
phase
}
=
useTypingEffect
()
return
(
return
(
<
section
<
section
id=
"hero"
className=
"section-shell pt-32 sm:pt-36"
aria
-
label=
"Hero section"
>
id=
"hero"
<
div
className=
"section-container"
>
className=
"relative flex items-center min-h-screen overflow-hidden"
<
div
className=
"grid gap-8 lg:grid-cols-[1fr_360px] lg:items-end"
>
aria
-
label=
"Hero section"
<
motion
.
div
>
variants=
{
variants
}
{
/* Animated Dot-Grid Background */
}
custom=
{
0
}
<
div
className=
"hero-bg pointer-events-none"
aria
-
hidden=
"true"
/>
initial=
"hidden"
animate=
"visible"
{
/* Content */
}
className=
"max-w-3xl"
<
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"
>
<
div
className=
"pill px-3 py-2 mb-5"
>
<
span
className=
"h-2 w-2 rounded-full bg-[var(--accent)]"
/>
{
/* Left — Staggered entrance */
}
Open to internship and junior backend roles
<
div
className=
"max-w-xl"
>
</
div
>
{
/* i=0 Eyebrow */
}
<
motion
.
p
variants=
{
variants
}
custom=
{
0
}
initial=
"hidden"
animate=
"visible"
className=
"muted-eyebrow mb-3"
>
Hey, I
'
m
</
motion
.
p
>
{
/* i=1 Name */
}
<
h1
className=
"text-[clamp(2.55rem,8vw,5.1rem)] tracking-normal"
>
<
motion
.
h1
variants=
{
variants
}
custom=
{
1
}
initial=
"hidden"
animate=
"visible"
className=
"gradient-text hero-name mb-4 leading-none"
>
{
personalInfo
.
name
}
{
personalInfo
.
name
}
</
motion
.
h1
>
</
h1
>
{
/* i=2 Typing role */
}
<
p
className=
"mt-4 text-xl font-semibold text-[var(--primary)] sm:text-2xl"
>
<
motion
.
div
{
personalInfo
.
role
}
variants=
{
variants
}
</
p
>
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
>
{
/* i=3 Short bio */
}
<
p
className=
"mt-5 max-w-2xl text-base leading-8 text-[var(--muted-2)] sm:text-lg"
>
<
motion
.
p
I build clean REST APIs, Spring Boot services, and database-backed systems with a focus on readability and steady growth.
variants=
{
variants
}
</
p
>
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
>
{
/* i=4 CTA buttons */
}
<
div
className=
"mt-8 flex flex-col gap-3 sm:flex-row"
>
<
motion
.
div
<
a
href=
"#projects"
className=
"btn-primary"
>
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)'
,
}
}
>
View Projects
View Projects
</
a
>
</
a
>
<
a
<
a
href=
{
`mailto:${personalInfo.email}`
}
className=
"btn-secondary"
>
href=
{
`mailto:${personalInfo.email}`
}
<
Mail
size=
{
18
}
/>
id=
"hero-contact-link"
Contact Me
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
>
</
a
>
</
motion
.
div
>
</
div
>
</
motion
.
div
>
{
/* i=5 Social icons + scroll arrow */
}
<
motion
.
aside
<
motion
.
div
variants=
{
variants
}
variants=
{
variants
}
custom=
{
1
}
custom=
{
5
}
initial=
"hidden"
initial=
"hidden"
animate=
"visible"
animate=
"visible"
className=
"card p-5"
className=
"flex items-center gap-4"
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
<
a
href=
{
personalInfo
.
githubUrl
}
href=
{
personalInfo
.
githubUrl
}
id=
"hero-github-link"
target=
"_blank"
target=
"_blank"
rel=
"noopener noreferrer"
rel=
"noopener noreferrer"
className=
"flex items-center gap-2 text-sm transition-all duration-200 group"
className=
"btn-secondary min-h-10 flex-1 px-3 py-2"
style=
{
{
color
:
'var(--color-muted)'
}
}
aria
-
label=
"GitHub profile"
aria
-
label=
"GitHub profile"
>
>
<
GithubIcon
size=
{
18
}
/>
<
GithubIcon
size=
{
17
}
/>
<
span
className=
"group-hover:text-[var(--color-text)] transition-colors"
>
GitHub
github.com/
{
personalInfo
.
github
}
</
span
>
</
a
>
</
a
>
<
span
style=
{
{
color
:
'var(--color-border-subtle)'
}
}
>
·
</
span
>
<
a
<
a
href=
{
`mailto:${personalInfo.email}`
}
href=
{
`mailto:${personalInfo.email}`
}
id=
"hero-email-link"
className=
"btn-secondary min-h-10 flex-1 px-3 py-2"
className=
"flex items-center gap-2 text-sm transition-all duration-200 group"
aria
-
label=
"Email"
style=
{
{
color
:
'var(--color-muted)'
}
}
aria
-
label=
"Email contact"
>
>
<
Mail
size=
{
18
}
className=
"group-hover:text-[var(--primary)] transition-colors"
/>
<
Mail
size=
{
17
}
/>
<
span
className=
"group-hover:text-[var(--color-text)] transition-colors"
>
Email
{
personalInfo
.
email
}
</
span
>
</
a
>
</
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
>
</
div
>
</
motion
.
div
>
</
motion
.
aside
>
</
div
>
</
div
>
</
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
>
</
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'
'use client'
import
{
useState
,
useEffect
,
useCallback
}
from
'react'
import
{
useCallback
,
useEffect
,
useState
}
from
'react'
import
{
motion
,
AnimatePresence
}
from
'framer-motion'
import
{
Menu
,
X
}
from
'lucide-react'
import
{
Menu
,
X
}
from
'lucide-react'
import
{
navLinks
,
personalInfo
}
from
'@/lib/data'
import
{
navLinks
,
personalInfo
}
from
'@/lib/data'
// ─── Active Section via Intersection Observer ────────────────
function
useActiveSection
(
sectionIds
:
string
[]):
string
{
function
useActiveSection
(
sectionIds
:
string
[]):
string
{
const
[
active
,
setActive
]
=
useState
<
string
>
(
''
)
const
[
active
,
setActive
]
=
useState
(
''
)
useEffect
(()
=>
{
useEffect
(()
=>
{
const
observers
:
IntersectionObserver
[]
=
[]
const
observers
:
IntersectionObserver
[]
=
[]
...
@@ -20,26 +18,27 @@ function useActiveSection(sectionIds: string[]): string {
...
@@ -20,26 +18,27 @@ function useActiveSection(sectionIds: string[]): string {
([
entry
])
=>
{
([
entry
])
=>
{
if
(
entry
.
isIntersecting
)
setActive
(
id
)
if
(
entry
.
isIntersecting
)
setActive
(
id
)
},
},
{
rootMargin
:
'-
40
% 0px -55% 0px'
,
threshold
:
0
}
{
rootMargin
:
'-
38
% 0px -55% 0px'
,
threshold
:
0
}
)
)
observer
.
observe
(
el
)
observer
.
observe
(
el
)
observers
.
push
(
observer
)
observers
.
push
(
observer
)
})
})
return
()
=>
observers
.
forEach
((
o
)
=>
o
.
disconnect
())
return
()
=>
observers
.
forEach
((
o
bserver
)
=>
observer
.
disconnect
())
},
[
sectionIds
])
},
[
sectionIds
])
return
active
return
active
}
}
export
default
function
Navbar
()
{
export
default
function
Navbar
()
{
const
[
scrolled
,
setScrolled
]
=
useState
(
false
)
const
[
mobileOpen
,
setMobileOpen
]
=
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
active
=
useActiveSection
(
sectionIds
)
const
handleScroll
=
useCallback
(()
=>
{
const
handleScroll
=
useCallback
(()
=>
{
setScrolled
(
window
.
scrollY
>
60
)
setScrolled
(
window
.
scrollY
>
24
)
},
[])
},
[])
useEffect
(()
=>
{
useEffect
(()
=>
{
...
@@ -49,128 +48,79 @@ export default function Navbar() {
...
@@ -49,128 +48,79 @@ export default function Navbar() {
const
handleNavClick
=
(
href
:
string
)
=>
{
const
handleNavClick
=
(
href
:
string
)
=>
{
setMobileOpen
(
false
)
setMobileOpen
(
false
)
const
id
=
href
.
slice
(
1
)
document
.
getElementById
(
href
.
slice
(
1
))?.
scrollIntoView
({
behavior
:
'smooth'
})
const
el
=
document
.
getElementById
(
id
)
el
?.
scrollIntoView
({
behavior
:
'smooth'
})
}
}
return
(
return
(
<>
<
header
<
motion
.
nav
className=
{
`fixed inset-x-0 top-0 z-50 border-b transition-colors ${
initial=
{
{
y
:
-
80
,
opacity
:
0
}
}
scrolled ? 'border-[var(--border)] bg-white/92 backdrop-blur' : 'border-transparent bg-white/70 backdrop-blur'
animate=
{
{
y
:
0
,
opacity
:
1
}
}
}`
}
transition=
{
{
duration
:
0.6
,
ease
:
'easeOut'
}
}
>
className=
{
`fixed top-0 left-0 right-0 z-50 ${
<
nav
className=
"section-container flex h-16 items-center justify-between"
aria
-
label=
"Main navigation"
>
scrolled ? 'scrolled backdrop-blur-md border-b border-white/5' : 'bg-transparent'
<
a
}`
}
href=
"#hero"
style=
{
{
onClick=
{
(
event
)
=>
{
backgroundColor
:
scrolled
?
'rgba(10,15,30,0.92)'
:
'transparent'
,
event
.
preventDefault
()
}
}
window
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
>
}
}
<
div
className=
"section-container"
>
className=
"flex items-center gap-3 font-semibold text-[var(--text)]"
<
div
className=
"flex items-center justify-between h-16"
>
aria
-
label=
"Back to top"
{
/* Logo Badge */
}
>
<
a
<
span
className=
"flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)] text-sm font-bold text-white"
>
href=
"#"
{
personalInfo
.
initials
}
onClick=
{
(
e
)
=>
{
e
.
preventDefault
();
window
.
scrollTo
({
top
:
0
,
behavior
:
'smooth'
})
}
}
</
span
>
className=
"flex items-center gap-2"
<
span
>
{
personalInfo
.
name
}
</
span
>
aria
-
label=
"Back to top"
</
a
>
>
<
span
<
ul
className=
"hidden items-center gap-1 md:flex"
>
className=
"inline-flex items-center justify-center w-10 h-10 rounded-xl font-bold text-sm tracking-widest"
{
navLinks
.
map
((
link
)
=>
{
style=
{
{
const
sectionId
=
link
.
href
.
slice
(
1
)
background
:
'linear-gradient(135deg, #F97316 0%, #FB923C 100%)'
,
const
isActive
=
active
===
sectionId
color
:
'#0A0F1E'
,
fontFamily
:
'var(--font-heading)'
,
return
(
boxShadow
:
'0 0 16px rgba(249,115,22,0.4)'
,
<
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
}
{
link
.
label
}
</
span
>
</
button
>
</
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
>
</
div
>
</
div
>
</
div
>
</
div
>
</
motion
.
nav
>
)
}
</
header
>
{
/* 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
>
</>
)
)
}
}
components/ProjectCard.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
{
motion
}
from
'framer-motion'
import
{
motion
}
from
'framer-motion'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
type
Project
}
from
'@/lib/data'
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
{
export
interface
ProjectCardProps
{
project
:
Project
project
:
Project
index
:
number
index
:
number
isInView
:
boolean
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
(
return
(
<
motion
.
div
<
motion
.
article
initial=
{
{
opacity
:
0
,
y
:
30
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.6
,
delay
:
index
*
0.1
}
}
transition=
{
{
duration
:
0.4
,
delay
:
index
*
0.06
}
}
className=
{
`project-card group relative rounded-2xl overflow-hidden flex flex-col ${
className=
"card flex h-full flex-col p-5"
large ? 'bento-featured' : tall ? 'bento-tall' : ''
}`
}
style=
{
{
minHeight
:
tall
?
'420px'
:
'auto'
,
}
}
>
>
<
div
<
div
className=
"mb-4 flex items-start justify-between gap-4"
>
aria
-
hidden=
"true"
<
div
>
className=
"absolute top-0 left-0 right-0 h-[2px] rounded-t-2xl"
<
p
className=
"mb-2 text-sm font-bold text-[var(--primary)]"
>
{
project
.
period
}
</
p
>
style=
{
{
background
:
'linear-gradient(90deg, #F97316, #FB923C)'
}
}
<
h3
className=
"text-xl"
>
{
project
.
name
}
</
h3
>
/>
{
project
.
role
&&
(
<
p
className=
"mt-2 text-sm font-semibold text-[var(--muted)]"
>
{
project
.
role
}
</
p
>
<
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
>
</
div
>
</
div
>
</
div
>
<
div
<
p
className=
"mb-5 flex-1 text-sm leading-7 text-[var(--muted-2)]"
>
{
project
.
description
}
</
p
>
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)'
}
}
<
div
className=
"mb-5 flex flex-wrap gap-2"
>
>
{
project
.
stack
.
slice
(
0
,
6
).
map
((
tech
)
=>
(
{
project
.
githubUrl
&&
(
<
span
key=
{
tech
}
className=
"tag"
>
<
a
{
tech
}
href=
{
project
.
githubUrl
}
</
span
>
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
>
)
}
</
div
>
</
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'
'use client'
import
{
useRef
,
Suspense
}
from
'react'
import
{
useRef
}
from
'react'
import
dynamic
from
'next/dynamic'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
motion
,
useInView
}
from
'framer-motion'
import
{
ExternalLink
}
from
'lucide-react'
import
GithubIcon
from
'@/components/GithubIcon'
import
{
projects
}
from
'@/lib/data'
import
ProjectCard
from
'@/components/ProjectCard'
import
SkeletonGrid
from
'@/components/SkeletonCard'
import
{
personalInfo
,
projects
}
from
'@/lib/data'
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
>
)
export
default
function
Projects
()
{
export
default
function
Projects
()
{
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
const
isInView
=
useInView
(
sectionRef
,
{
once
:
true
,
margin
:
'-80px'
})
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
(
return
(
<
section
<
section
id=
"projects"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Projects section"
>
id=
"projects"
ref=
{
sectionRef
}
className=
"py-24"
aria
-
label=
"Projects section"
>
<
div
className=
"section-container"
>
<
div
className=
"section-container"
>
<
motion
.
div
<
motion
.
div
initial=
{
{
opacity
:
0
,
y
:
20
}
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
transition=
{
{
duration
:
0.
6
}
}
transition=
{
{
duration
:
0.
45
}
}
className=
"
mb-16
"
className=
"
section-heading flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between
"
>
>
<
p
<
div
>
className=
"muted-eyebrow mb-3"
<
p
className=
"section-kicker"
>
Projects
</
p
>
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
<
h2
className=
"section-title"
>
Selected backend work.
</
h2
>
>
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
>
</
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
<
a
href=
"https://github.com/datdat054"
href=
{
personalInfo
.
githubUrl
}
target=
"_blank"
target=
"_blank"
rel=
"noopener noreferrer"
rel=
"noopener noreferrer"
id=
"projects-github-all"
className=
"btn-secondary w-fit"
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)'
,
}
}
>
>
<
GithubIcon
size=
{
16
}
/>
<
GithubIcon
size=
{
18
}
/>
View All on GitHub
GitHub
<
ExternalLink
size=
{
14
}
/>
</
a
>
</
a
>
</
motion
.
div
>
</
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
>
</
div
>
</
section
>
</
section
>
)
)
...
...
components/SkeletonCard.tsx
View file @
3eb5b4e1
'use client'
'use client'
export
function
SkeletonCard
(
{
large
=
false
,
tall
=
false
}:
{
large
?:
boolean
;
tall
?:
boolean
}
)
{
export
function
SkeletonCard
()
{
return
(
return
(
<
div
<
div
className=
"card flex min-h-[240px] animate-pulse flex-col justify-between p-5"
>
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=
"space-y-4"
>
<
div
className=
"space-y-4"
>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"h-5 w-1/3 rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-6 w-1/3 bg-white/10 rounded"
/>
<
div
className=
"h-7 w-2/3 rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-4 w-12 bg-white/10 rounded"
/>
<
div
className=
"h-4 w-full rounded bg-[var(--surface-2)]"
/>
</
div
>
<
div
className=
"h-4 w-4/5 rounded bg-[var(--surface-2)]"
/>
<
div
className=
"h-4 w-3/4 bg-white/10 rounded"
/>
<
div
className=
"h-4 w-1/2 bg-white/10 rounded"
/>
</
div
>
</
div
>
<
div
className=
"flex gap-2 pt-6"
>
<
div
className=
"flex gap-2 pt-6"
>
<
div
className=
"h-
6 w-16 bg-white/10 rounded
"
/>
<
div
className=
"h-
7 w-16 rounded-full bg-[var(--surface-2)]
"
/>
<
div
className=
"h-
6 w-20 bg-white/10 rounded
"
/>
<
div
className=
"h-
7 w-20 rounded-full bg-[var(--surface-2)]
"
/>
<
div
className=
"h-
6 w-14 bg-white/10 rounded
"
/>
<
div
className=
"h-
7 w-14 rounded-full bg-[var(--surface-2)]
"
/>
</
div
>
</
div
>
</
div
>
</
div
>
)
)
...
@@ -29,19 +20,11 @@ export function SkeletonCard({ large = false, tall = false }: { large?: boolean;
...
@@ -29,19 +20,11 @@ export function SkeletonCard({ large = false, tall = false }: { large?: boolean;
export
default
function
SkeletonGrid
()
{
export
default
function
SkeletonGrid
()
{
return
(
return
(
<
div
className=
"bento-grid"
>
<
div
className=
"grid gap-4 md:grid-cols-2"
>
<
div
className=
"bento-featured"
style=
{
{
gridColumn
:
'span 2'
,
gridRow
:
'span 1'
}
}
>
<
SkeletonCard
/>
<
SkeletonCard
large
/>
<
SkeletonCard
/>
</
div
>
<
SkeletonCard
/>
<
div
className=
"bento-card2"
style=
{
{
gridColumn
:
'3'
,
gridRow
:
'span 2'
}
}
>
<
SkeletonCard
/>
<
SkeletonCard
tall
/>
</
div
>
<
div
className=
"bento-card3"
>
<
SkeletonCard
/>
</
div
>
<
div
className=
"bento-card4"
>
<
SkeletonCard
/>
</
div
>
</
div
>
</
div
>
)
)
}
}
components/Skills.tsx
View file @
3eb5b4e1
'use client'
'use client'
import
{
useRef
,
useState
}
from
'react'
import
{
useRef
,
useState
}
from
'react'
import
{
motion
,
AnimatePresence
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
motion
,
useInView
,
useReducedMotion
}
from
'framer-motion'
import
{
fadeUp
,
fadeUpReduced
}
from
'@/lib/animations'
import
{
skillCategories
,
type
SkillTab
}
from
'@/lib/data'
import
{
skillCategories
,
type
SkillTab
,
type
Skill
}
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
()
{
export
default
function
Skills
()
{
const
[
activeTab
,
setActiveTab
]
=
useState
<
SkillTab
>
(
'Backend'
)
const
[
activeTab
,
setActiveTab
]
=
useState
<
SkillTab
>
(
'Backend'
)
const
sectionRef
=
useRef
<
HTMLElement
>
(
null
)
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
prefersReduced
=
useReducedMotion
()
const
currentCategory
=
skillCategories
.
find
((
c
)
=>
c
.
tab
===
activeTab
)
!
const
tabs
:
SkillTab
[]
=
[
'Backend'
,
'Frontend'
,
'DevOps'
,
'Database'
]
const
tabs
:
SkillTab
[]
=
[
'Backend'
,
'Frontend'
,
'DevOps'
,
'Database'
]
const
headingVariants
=
prefersReduced
?
fadeUpReduced
:
fadeUp
const
currentCategory
=
skillCategories
.
find
((
category
)
=>
category
.
tab
===
activeTab
)
!
return
(
return
(
<
section
<
section
id=
"skills"
ref=
{
sectionRef
}
className=
"section-shell"
aria
-
label=
"Skills section"
>
id=
"skills"
ref=
{
sectionRef
}
className=
"py-24"
style=
{
{
background
:
'var(--color-surface)'
}
}
aria
-
label=
"Skills section"
>
<
div
className=
"section-container"
>
<
div
className=
"section-container"
>
{
/* Section Heading */
}
<
motion
.
div
<
motion
.
div
variants=
{
headingVariants
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
custom=
{
0
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
initial=
"hidden"
transition=
{
{
duration
:
0.45
}
}
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"section-heading"
className=
"mb-16"
>
>
<
p
className=
"muted-eyebrow mb-3"
style=
{
{
fontFamily
:
'var(--font-mono)'
}
}
>
<
p
className=
"section-kicker"
>
Skills
</
p
>
02. Skills
<
h2
className=
"section-title"
>
Tools I use most often.
</
h2
>
</
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)'
}
}
/>
</
motion
.
div
>
</
motion
.
div
>
{
/* Tab Switcher */
}
<
motion
.
div
<
motion
.
div
variants=
{
headingVariants
}
initial=
{
{
opacity
:
0
,
y
:
18
}
}
custom=
{
1
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
initial=
"hidden"
transition=
{
{
duration
:
0.45
,
delay
:
0.08
}
}
animate=
{
isInView
?
'visible'
:
'hidden'
}
className=
"mb-6 flex flex-wrap gap-2"
className=
"flex flex-wrap gap-2 mb-10"
role=
"tablist"
role=
"tablist"
aria
-
label=
"Skill categories"
aria
-
label=
"Skill categories"
>
>
{
tabs
.
map
((
tab
)
=>
(
{
tabs
.
map
((
tab
)
=>
(
<
button
<
button
key=
{
tab
}
key=
{
tab
}
type=
"button"
role=
"tab"
role=
"tab"
id=
{
`skills-tab-${tab.toLowerCase()}`
}
aria
-
selected=
{
activeTab
===
tab
}
aria
-
selected=
{
activeTab
===
tab
}
onClick=
{
()
=>
setActiveTab
(
tab
)
}
onClick=
{
()
=>
setActiveTab
(
tab
)
}
className=
"relative px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200 focus:outline-none"
className=
{
`rounded-md border px-4 py-2 text-sm font-bold transition-colors ${
style=
{
{
activeTab === tab
background
:
activeTab
===
tab
?
'linear-gradient(135deg, #F97316, #FB923C)'
:
'rgba(255,255,255,0.05)'
,
? 'border-[var(--primary)] bg-[var(--primary)] text-white'
color
:
activeTab
===
tab
?
'#0A0F1E'
:
'var(--color-muted2)'
,
: 'border-[var(--border)] bg-white text-[var(--muted)] hover:text-[var(--text)]'
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'
,
}
}
>
>
{
tab
}
{
tab
}
</
button
>
</
button
>
))
}
))
}
</
motion
.
div
>
</
motion
.
div
>
{
/* Skills Grid — AnimatePresence tab slide */
}
<
div
className=
"grid gap-4 sm:grid-cols-2"
>
<
AnimatePresence
mode=
"wait"
>
{
currentCategory
.
skills
.
map
((
skill
,
index
)
=>
(
<
motion
.
div
<
motion
.
div
key=
{
activeTab
}
key=
{
`${activeTab}-${skill.name}`
}
initial=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
16
}
}
initial=
{
{
opacity
:
0
,
y
:
prefersReduced
?
0
:
14
}
}
animate=
{
prefersReduced
?
{
opacity
:
1
}
:
{
opacity
:
1
,
x
:
0
}
}
animate=
{
isInView
?
{
opacity
:
1
,
y
:
0
}
:
{}
}
exit=
{
prefersReduced
?
{
opacity
:
0
}
:
{
opacity
:
0
,
x
:
-
16
}
}
transition=
{
{
duration
:
0.35
,
delay
:
index
*
0.03
}
}
transition=
{
{
duration
:
prefersReduced
?
0.2
:
0.22
}
}
className=
"card p-4"
role=
"tabpanel"
>
aria
-
labelledby=
{
`skills-tab-${activeTab.toLowerCase()}`
}
<
div
className=
"mb-3 flex items-center justify-between gap-4"
>
className=
"grid grid-cols-1 sm:grid-cols-2 gap-x-12 gap-y-6"
<
span
className=
"font-semibold text-[var(--text)]"
>
{
skill
.
name
}
</
span
>
>
<
span
className=
"text-sm font-bold text-[var(--primary)]"
>
{
skill
.
level
}
%
</
span
>
{
currentCategory
.
skills
.
map
((
skill
,
i
)
=>
(
</
div
>
<
SkillBar
<
div
className=
"progress-track"
>
key=
{
`${activeTab}-${skill.name}`
}
<
motion
.
div
skill=
{
skill
}
className=
"progress-fill"
trigger=
{
isInView
}
initial=
{
{
width
:
0
}
}
index=
{
i
}
animate=
{
isInView
?
{
width
:
`${skill.level}%`
}
:
{
width
:
0
}
}
prefersReduced=
{
prefersReduced
}
transition=
{
{
duration
:
prefersReduced
?
0
:
0.8
,
delay
:
index
*
0.03
}
}
/>
/>
))
}
</
div
>
</
motion
.
div
>
</
motion
.
div
>
</
AnimatePresence
>
))
}
</
div
>
{
/* 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
>
</
div
>
</
section
>
</
section
>
)
)
...
...
components/TopProgressBar.tsx
View file @
3eb5b4e1
...
@@ -9,9 +9,12 @@ function ProgressBarContent() {
...
@@ -9,9 +9,12 @@ function ProgressBarContent() {
const
[
loading
,
setLoading
]
=
useState
(
false
)
const
[
loading
,
setLoading
]
=
useState
(
false
)
useEffect
(()
=>
{
useEffect
(()
=>
{
setLoading
(
true
)
const
start
=
setTimeout
(()
=>
setLoading
(
true
),
0
)
const
timer
=
setTimeout
(()
=>
setLoading
(
false
),
500
)
const
timer
=
setTimeout
(()
=>
setLoading
(
false
),
500
)
return
()
=>
clearTimeout
(
timer
)
return
()
=>
{
clearTimeout
(
start
)
clearTimeout
(
timer
)
}
},
[
pathname
,
searchParams
])
},
[
pathname
,
searchParams
])
if
(
!
loading
)
return
null
if
(
!
loading
)
return
null
...
@@ -20,8 +23,8 @@ function ProgressBarContent() {
...
@@ -20,8 +23,8 @@ function ProgressBarContent() {
<
div
<
div
className=
"fixed top-0 left-0 right-0 h-0.5 z-[10000] animate-pulse transition-all duration-300"
className=
"fixed top-0 left-0 right-0 h-0.5 z-[10000] animate-pulse transition-all duration-300"
style=
{
{
style=
{
{
background
:
'
linear-gradient(90deg, #F97316, #FB923C
)'
,
background
:
'
var(--primary
)'
,
boxShadow
:
'0 0 10px
#F97316
'
,
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