Commit 3eb5b4e1 authored by DatLD's avatar DatLD

Update portfolio UI

parent b53436f5
@import 'tailwindcss';
/* ============================================================
CSS Custom Properties — Design Tokens
============================================================ */
:root {
--bg: #0A0F1E;
--surface: #111827;
--surface-2: #1A2236;
--border: rgba(249, 115, 22, 0.15);
--primary: #F97316;
--accent: #FB923C;
--text: #F1F5F9;
--muted: #64748B;
--gradient: linear-gradient(135deg, #F97316, #FB923C);
/* Typography */
--font-heading: var(--font-space, var(--font-space-grotesk)), 'Space Grotesk', system-ui, sans-serif;
--font-body: var(--font-inter), 'Inter', system-ui, sans-serif;
--font-mono: var(--font-mono, var(--font-jetbrains-mono)), 'JetBrains Mono', 'Fira Code', monospace;
/* Spacing & Radius */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
/* Aliases for backwards compatibility */
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface2: var(--surface-2);
--color-primary: var(--primary);
--color-accent: var(--accent);
--color-text: var(--text);
--color-muted: var(--muted);
--color-muted2: var(--muted);
--bg: #f7f8fb;
--surface: #ffffff;
--surface-2: #eef2f7;
--border: #d9e0ea;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--accent: #0f766e;
--text: #111827;
--muted: #5b6472;
--muted-2: #374151;
--font-heading: var(--font-space, var(--font-space-grotesk)), system-ui, sans-serif;
--font-body: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-mono, var(--font-jetbrains-mono)), ui-monospace, monospace;
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-surface2: var(--surface-2);
--color-primary: var(--primary);
--color-accent: var(--accent);
--color-text: var(--text);
--color-muted: var(--muted);
--color-muted2: var(--muted-2);
--color-border-subtle: var(--border);
--glow-primary: 0 0 24px rgba(249, 115, 22, 0.35);
--glow-sm: 0 0 12px rgba(249, 115, 22, 0.2);
}
/* ============================================================
Tailwind v4 Theme Extension
============================================================ */
@theme inline {
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-surface2: var(--color-surface2);
--color-primary: var(--color-primary);
--color-accent: var(--color-accent);
--color-text: var(--color-text);
--color-muted: var(--color-muted);
--color-muted2: var(--color-muted2);
--font-sans: var(--font-body);
--font-mono: var(--font-mono);
--color-bg: var(--color-bg);
--color-surface: var(--color-surface);
--color-surface2: var(--color-surface2);
--color-primary: var(--color-primary);
--color-accent: var(--color-accent);
--color-text: var(--color-text);
--color-muted: var(--color-muted);
--color-muted2: var(--color-muted2);
--font-sans: var(--font-body);
--font-mono: var(--font-mono);
}
/* ============================================================
Base Reset & Global Styles
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
scroll-padding-top: 88px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--bg);
min-height: 100vh;
margin: 0;
background:
linear-gradient(180deg, rgba(37, 99, 235, 0.06), transparent 320px),
var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.75;
min-height: 100vh;
line-height: 1.65;
overflow-x: hidden;
}
/* ============================================================
Typography Defaults
============================================================ */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.2;
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
color: var(--text);
}
.hero-name {
font-size: clamp(2.8rem, 7vw, 5.5rem);
font-weight: 700;
font-family: var(--font-heading);
}
.section-title {
font-size: clamp(1.6rem, 3.5vw, 2.25rem);
font-weight: 600;
font-family: var(--font-heading);
}
.muted-eyebrow {
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
font-weight: 700;
line-height: 1.12;
}
p {
color: var(--color-muted2);
line-height: 1.7;
}
code, pre, kbd {
font-family: var(--font-mono);
margin: 0;
color: var(--muted-2);
}
a {
......@@ -125,315 +89,149 @@ a {
text-decoration: none;
}
/* ============================================================
Scrollbar Styling
============================================================ */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg);
}
::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-accent);
button,
input,
textarea {
font: inherit;
}
/* ============================================================
Selection
============================================================ */
::selection {
background: rgba(249, 115, 22, 0.3);
color: var(--color-text);
}
/* ============================================================
Hero Dot-Grid Animation
============================================================ */
@keyframes gridPan {
from { background-position: 0 0; }
to { background-position: 28px 28px; }
background: rgba(37, 99, 235, 0.16);
}
.hero-bg {
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(249,115,22,0.18) 1px, transparent 1px);
background-size: 28px 28px;
animation: gridPan 8s linear infinite;
}
.hero-bg::after {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(ellipse at center, transparent 40%, #0A0F1E 100%);
}
/* ============================================================
Terminal Float Animation
============================================================ */
@keyframes terminalFloat {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
::-webkit-scrollbar {
width: 8px;
}
.terminal-float {
animation: terminalFloat 4s ease-in-out infinite;
::-webkit-scrollbar-track {
background: var(--surface-2);
}
/* ============================================================
Blinking Cursor
============================================================ */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
::-webkit-scrollbar-thumb {
background: #b8c2d2;
border-radius: 999px;
}
.cursor-blink {
animation: blink 1s step-end infinite;
.section-container {
width: min(100% - 2rem, 1120px);
margin-inline: auto;
}
/* Typing cursor — orange, 0.7s step-end */
.typing-cursor {
animation: blink 0.7s step-end infinite;
.section-shell {
padding-block: clamp(3.5rem, 7vw, 5.5rem);
}
/* ============================================================
Navbar — Scrolled State
============================================================ */
nav {
transition: background-color 0.3s ease, backdrop-filter 0.3s ease;
.section-heading {
margin-bottom: 2rem;
}
nav.scrolled {
background: rgba(10, 15, 30, 0.92) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
.section-kicker {
margin-bottom: 0.6rem;
color: var(--primary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
/* ============================================================
Scroll-down Bounce
============================================================ */
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
.section-title {
max-width: 680px;
font-size: clamp(1.8rem, 3.5vw, 2.8rem);
letter-spacing: 0;
}
.scroll-bounce {
animation: scrollBounce 1.5s ease-in-out infinite;
.card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
}
/* ============================================================
Section Divider
============================================================ */
.section-divider {
border: none;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(249, 115, 22, 0.25) 30%, rgba(249, 115, 22, 0.25) 70%, transparent);
margin: 0;
.pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
color: var(--muted);
font-size: 0.82rem;
font-weight: 600;
line-height: 1;
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 44px;
border-radius: 8px;
padding: 0.72rem 1.05rem;
font-size: 0.92rem;
font-weight: 700;
transition: background 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease;
}
/* ============================================================
Orange gradient text utility
============================================================ */
.gradient-text {
background: linear-gradient(135deg, #F97316 0%, #FB923C 50%, #FED7AA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
background-size: 200% 200%;
animation: shimmer 4s ease-in-out infinite;
.btn-primary {
background: var(--primary);
color: #fff;
}
@keyframes shimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
/* ============================================================
Glass Card utility
============================================================ */
.glass-card {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
.btn-secondary {
border: 1px solid var(--border);
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
}
.glass-card:hover {
border-color: var(--primary);
box-shadow: var(--glow-primary);
background: #fff;
color: var(--text);
}
.project-card, .skill-card {
background: rgba(17, 24, 39, 0.8);
border: 1px solid rgba(249, 115, 22, 0.15);
backdrop-filter: blur(12px);
border-radius: 12px;
transition: border-color 0.3s, box-shadow 0.3s;
.btn-secondary:hover {
border-color: #b7c2d4;
transform: translateY(-1px);
}
.project-card:hover, .skill-card:hover {
border-color: rgba(249, 115, 22, 0.5);
box-shadow: 0 0 24px rgba(249, 115, 22, 0.1);
.tag {
display: inline-flex;
align-items: center;
border: 1px solid #cfd8e6;
border-radius: 999px;
background: #f8fafc;
color: #334155;
padding: 0.38rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
}
/* ============================================================
Progress Bar Base
============================================================ */
.progress-track {
background: var(--surface-2);
height: 6px;
border-radius: 3px;
position: relative;
height: 6px;
overflow: hidden;
border-radius: 999px;
background: #dbe3ee;
}
.progress-fill {
background: linear-gradient(90deg, #F97316, #FB923C);
border-radius: 3px;
height: 100%;
border-radius: inherit;
background: var(--primary);
}
/* ============================================================
Section Base
============================================================ */
.section-container {
max-width: 1280px;
margin-left: auto;
margin-right: auto;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
/* ============================================================
Timeline Connector
============================================================ */
.timeline-line {
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, #F97316, rgba(249, 115, 22, 0.1));
}
/* ============================================================
Contact Input Animated Border
============================================================ */
.input-field {
position: relative;
background: transparent;
border: none;
border-bottom: 1px solid var(--color-border-subtle);
color: var(--color-text);
width: 100%;
padding: 0.75rem 0;
font-family: var(--font-body);
font-size: 0.95rem;
outline: none;
transition: border-color 0.3s ease;
}
.input-wrapper {
position: relative;
}
.input-wrapper::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(90deg, #F97316, #FB923C);
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.input-wrapper:focus-within::after {
width: 100%;
}
/* ============================================================
Code Block Syntax (IntelliJ Darcula)
============================================================ */
.code-block {
background: #2B2B2B;
border-radius: 10px;
border: 1px solid #3C3F41;
padding: 1.25rem 1.5rem;
font-family: "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.8;
}
.line-numbers { color: #606366; margin-right: 1.5rem; user-select: none; }
.kw { color: #CC7832; } /* keywords: public, class, private, return, void */
.str { color: #6A8759; } /* string literals */
.cmt { color: #808080; } /* comments */
.fn { color: #FFC66D; } /* method names */
.type { color: #A9B7C6; } /* types: String, int */
.ann { color: #BBB529; } /* annotations: @Override */
/* ============================================================
Bento Grid
============================================================ */
.bento-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto auto;
gap: 1.25rem;
}
@media (max-width: 1024px) {
.bento-grid {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"featured featured"
"card2 card2"
"card3 card4";
}
.bento-featured { grid-area: featured; }
.bento-card2 { grid-area: card2; }
.bento-card3 { grid-area: card3; }
.bento-card4 { grid-area: card4; }
*:focus-visible {
outline: 3px solid rgba(37, 99, 235, 0.35);
outline-offset: 3px;
}
@media (max-width: 640px) {
.bento-grid {
grid-template-columns: 1fr;
grid-template-areas:
"featured"
"card2"
"card3"
"card4";
}
}
/* ============================================================
Responsive Utilities
============================================================ */
@media (max-width: 375px) {
.section-container {
padding-left: 1rem;
padding-right: 1rem;
width: min(100% - 1.25rem, 1120px);
}
}
/* ============================================================
Accessibility & Polish
============================================================ */
html {
scroll-behavior: smooth;
scroll-padding-top: 80px;
}
*:focus-visible {
outline: 2px solid #F97316 !important;
outline-offset: 2px !important;
.btn-primary,
.btn-secondary {
width: 100%;
}
}
......@@ -2,35 +2,17 @@ import Link from 'next/link'
export default function NotFound() {
return (
<div
className="min-h-screen flex flex-col items-center justify-center p-6 text-center"
style={{ background: 'var(--bg)' }}
>
<h1
className="text-8xl font-extrabold mb-4 gradient-text"
style={{ fontFamily: 'var(--font-heading)' }}
>
<div className="flex min-h-screen flex-col items-center justify-center bg-[var(--bg)] p-6 text-center">
<h1 className="mb-4 text-7xl font-extrabold text-[var(--primary)] sm:text-8xl">
404
</h1>
<p
className="text-2xl font-semibold mb-2"
style={{ color: 'var(--color-text)', fontFamily: 'var(--font-heading)' }}
>
<p className="mb-2 text-2xl font-semibold text-[var(--text)]">
Page not found.
</p>
<p className="text-sm mb-8 max-w-md" style={{ color: 'var(--color-muted)' }}>
<p className="mb-8 max-w-md text-sm text-[var(--muted)]">
Sorry, the page you are looking for does not exist or has been moved.
</p>
<Link
href="/"
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-105"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
fontFamily: 'var(--font-heading)',
boxShadow: '0 0 20px rgba(249,115,22,0.3)',
}}
>
<Link href="/" className="btn-primary">
Back to Home
</Link>
</div>
......
......@@ -14,17 +14,12 @@ export default function Home() {
<>
<TopProgressBar />
<Navbar />
<main id="main-content">
<main id="main-content" className="overflow-hidden">
<Hero />
<hr className="section-divider" />
<About />
<hr className="section-divider" />
<Skills />
<hr className="section-divider" />
<Projects />
<hr className="section-divider" />
<Experience />
<hr className="section-divider" />
<Contact />
</main>
<Footer />
......
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, useInView } from 'framer-motion'
import { personalInfo, stats, type Stat } from '@/lib/data'
import { useRef } from 'react'
import { personalInfo, stats } from '@/lib/data'
// ─── Count-up hook ────────────────────────────────────────────
function useCountUp(target: number, duration = 1800, trigger: boolean) {
const [count, setCount] = useState(0)
const startRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
useEffect(() => {
if (!trigger) return
startRef.current = null
const step = (timestamp: number) => {
if (!startRef.current) startRef.current = timestamp
const elapsed = timestamp - startRef.current
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3)
setCount(Math.floor(eased * target))
if (progress < 1) {
rafRef.current = requestAnimationFrame(step)
}
}
rafRef.current = requestAnimationFrame(step)
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
}
}, [trigger, target, duration])
return count
}
// ─── Stat Counter Card ────────────────────────────────────────
function StatCard({ stat, trigger }: { stat: Stat; trigger: boolean }) {
const value = useCountUp(stat.value, 1600, trigger)
return (
<div className="flex flex-col items-center text-center p-5 rounded-xl glass-card">
<span
className="text-3xl sm:text-4xl font-bold gradient-text mb-1"
style={{ fontFamily: 'var(--font-heading)' }}
>
{value}{stat.suffix}
</span>
<span
className="text-xs uppercase tracking-widest font-medium"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
{stat.label}
</span>
</div>
)
}
// ─── Darcula Code Line ────────────────────────────────────────
interface CodeLine {
lineNum: number
content: React.ReactNode
}
function CodeBlock() {
const codeLines: CodeLine[] = [
{
lineNum: 1,
content: (
<span>
<span className="kw">public class </span>
<span className="nm">AboutMe</span>
<span className="nm"> {'{'}</span>
</span>
),
},
{
lineNum: 2,
content: (
<span>
{' '}<span className="kw">private </span>
<span className="kw">String </span>
<span className="nm">name </span>
<span className="nm">= </span>
<span className="str">&quot;Luu Duc Dat&quot;</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">&quot;Java Backend Developer&quot;</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">&quot;Ho Chi Minh City&quot;</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">&quot;PTIT HCMC — GPA 3.0/4.0&quot;</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">&quot;Spring Boot&quot;</span>
<span className="nm">, </span>
<span className="str">&quot;Microservices&quot;</span>
<span className="nm">, </span>
<span className="str">&quot;PostgreSQL&quot;</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">&quot;Senior Backend Engineer in 3 years&quot;</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">&quot;Always building. Always learning.&quot;</span>
<span className="nm">;</span>
</span>
),
},
{
lineNum: 15,
content: <span><span className="nm">{' }'}</span></span>,
},
{ lineNum: 16, content: <span><span className="nm">{'}'}</span></span> },
]
return (
<div className="code-block rounded-xl overflow-hidden">
{/* Mac-style title bar */}
<div
className="flex items-center gap-2 px-4 py-3 -mx-6 -mt-5 mb-3"
style={{ background: '#1E1E2E', borderBottom: '1px solid #3C3F41' }}
>
<span className="w-3 h-3 rounded-full" style={{ background: '#FF5F57' }} />
<span className="w-3 h-3 rounded-full" style={{ background: '#FEBC2E' }} />
<span className="w-3 h-3 rounded-full" style={{ background: '#28C840' }} />
<span
className="ml-2 text-xs"
style={{ color: '#808080', fontFamily: 'var(--font-mono)' }}
>
AboutMe.java
</span>
</div>
{/* Code lines */}
<div className="overflow-x-auto">
{codeLines.map((line) => (
<div key={line.lineNum} className="flex items-start hover:bg-white/5 transition-colors">
<span className="line-numbers select-none text-right w-8 shrink-0 pr-4">
{line.lineNum}
</span>
<span className="whitespace-pre">{line.content}</span>
</div>
))}
</div>
</div>
)
}
// ─── About Section ────────────────────────────────────────────
export default function About() {
const sectionRef = useRef<HTMLElement>(null)
const statsRef = useRef<HTMLDivElement>(null)
const isInView = useInView(sectionRef, { once: true, margin: '-100px' })
const statsInView = useInView(statsRef, { once: true, margin: '-50px' })
const isInView = useInView(sectionRef, { once: true, margin: '-80px' })
const bioLines = personalInfo.bio.split('\n\n')
return (
<section id="about" ref={sectionRef} className="py-24" aria-label="About section">
<section id="about" ref={sectionRef} className="section-shell" aria-label="About section">
<div className="section-container">
{/* Section Heading */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
transition={{ duration: 0.45 }}
className="section-heading"
>
<p
className="muted-eyebrow mb-3"
style={{ fontFamily: 'var(--font-mono)' }}
>
01. About
</p>
<h2
className="section-title"
>
Who I Am
</h2>
<div
className="mt-3 h-1 w-16 rounded-full"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
<p className="section-kicker">About</p>
<h2 className="section-title">Backend-focused, practical, and always learning.</h2>
</motion.div>
{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
{/* Left — Text */}
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.2 }}
className="space-y-5"
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45, delay: 0.08 }}
className="card p-6 sm:p-7"
>
{bioLines.map((line, i) => (
<p key={i} className="text-base leading-relaxed" style={{ color: 'var(--color-muted2)' }}>
{line}
</p>
))}
<div className="space-y-5">
{bioLines.map((line) => (
<p key={line} className="text-base leading-8 text-[var(--muted-2)]">
{line}
</p>
))}
</div>
{/* Tags */}
<div className="flex flex-wrap gap-2 pt-2">
{['OOP', 'Clean Code', 'API Design', 'System Design', 'Distributed Systems'].map((tag) => (
<span
key={tag}
className="px-3 py-1 rounded-full text-xs font-medium"
style={{
background: 'rgba(249,115,22,0.12)',
color: 'var(--primary)',
border: '1px solid rgba(249,115,22,0.25)',
fontFamily: 'var(--font-mono)',
}}
>
<div className="mt-7 flex flex-wrap gap-2">
{['Java', 'Spring Boot', 'REST APIs', 'Microservices', 'Databases'].map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</div>
</motion.div>
{/* Right — Code Block */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.35 }}
>
<CodeBlock />
</motion.div>
</div>
{/* Stats Row */}
<div ref={statsRef} className="mt-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={statsInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1 }}
className="grid grid-cols-3 gap-4 sm:gap-6"
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45, delay: 0.16 }}
className="grid gap-3"
>
{stats.map((stat) => (
<StatCard key={stat.label} stat={stat} trigger={statsInView} />
<div key={stat.label} className="card p-5">
<div className="text-3xl font-bold text-[var(--primary)]">
{stat.value}
{stat.suffix}
</div>
<div className="mt-1 text-sm font-semibold text-[var(--muted)]">{stat.label}</div>
</div>
))}
</motion.div>
</div>
......
......@@ -27,11 +27,9 @@ export default function BackToTop() {
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
onClick={scrollToTop}
className="fixed bottom-6 right-6 z-50 flex items-center justify-center w-[44px] h-[44px] rounded-full shadow-lg hover:scale-110 transition-transform duration-200 cursor-pointer"
className="fixed bottom-6 right-6 z-50 flex h-11 w-11 cursor-pointer items-center justify-center rounded-md border border-[var(--border)] bg-white text-[var(--primary)] shadow-lg transition-transform duration-200 hover:scale-105"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
boxShadow: '0 0 20px rgba(249,115,22,0.4)',
boxShadow: '0 12px 30px rgba(15, 23, 42, 0.14)',
}}
aria-label="Back to top"
>
......
'use client'
import { useRef, useState } from 'react'
import { motion, useInView } from 'framer-motion'
import { FormEvent, useRef, useState } from 'react'
import { Mail, Send } from 'lucide-react'
import { motion, useInView } from 'framer-motion'
import GithubIcon from '@/components/GithubIcon'
import { personalInfo } from '@/lib/data'
// ─── SVG Brand Icons ──────────────────────────────────────────
const GithubIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
</svg>
)
const LinkedinIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
)
// ─── Animated Input ────────────────────────────────────────────
interface InputProps {
function Field({
id,
label,
value,
onChange,
type = 'text',
multiline = false,
}: {
id: string
label: string
type?: string
value: string
onChange: (v: string) => void
onChange: (value: string) => void
type?: string
multiline?: boolean
}
function AnimatedInput({ id, label, type = 'text', value, onChange, multiline = false }: InputProps) {
const [focused, setFocused] = useState(false)
const hasValue = value.length > 0
const baseStyle: React.CSSProperties = {
width: '100%',
background: 'transparent',
border: 'none',
outline: 'none',
color: 'var(--color-text)',
fontFamily: 'var(--font-body)',
fontSize: '0.95rem',
paddingTop: '1.5rem',
paddingBottom: '0.5rem',
resize: 'none' as const,
}
const labelStyle: React.CSSProperties = {
position: 'absolute' as const,
left: 0,
top: focused || hasValue ? '0.25rem' : '1rem',
fontSize: focused || hasValue ? '0.7rem' : '0.95rem',
color: focused ? 'var(--color-primary)' : 'var(--color-muted)',
fontFamily: 'var(--font-body)',
transition: 'all 0.25s cubic-bezier(0.4,0,0.2,1)',
pointerEvents: 'none',
letterSpacing: focused || hasValue ? '0.06em' : '0',
textTransform: focused || hasValue ? 'uppercase' : 'none',
}
}) {
const classes =
'mt-2 w-full rounded-md border border-[var(--border)] bg-white px-3 py-3 text-[var(--text)] outline-none transition-colors placeholder:text-slate-400 focus:border-[var(--primary)]'
return (
<div className="input-wrapper relative pb-1" style={{ borderBottom: `1px solid ${focused ? 'var(--color-primary)' : 'rgba(255,255,255,0.1)'}`, transition: 'border-color 0.3s' }}>
<label htmlFor={id} style={labelStyle}>{label}</label>
<label htmlFor={id} className="block text-sm font-bold text-[var(--text)]">
{label}
{multiline ? (
<textarea
id={id}
rows={4}
rows={5}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={baseStyle}
onChange={(event) => onChange(event.target.value)}
className={classes}
/>
) : (
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={baseStyle}
onChange={(event) => onChange(event.target.value)}
className={classes}
/>
)}
{/* Animated sweep underline */}
<div
style={{
position: 'absolute',
bottom: -1,
left: 0,
height: '2px',
width: focused ? '100%' : '0',
background: 'linear-gradient(90deg, #F97316, #FB923C)',
transition: 'width 0.4s cubic-bezier(0.4,0,0.2,1)',
borderRadius: '999px',
}}
/>
</div>
</label>
)
}
// ─── Contact Section ──────────────────────────────────────────
export default function Contact() {
const sectionRef = useRef<HTMLElement>(null)
const isInView = useInView(sectionRef, { once: true, margin: '-80px' })
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [message, setMessage] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
const subject = encodeURIComponent(`Portfolio Contact from ${name}`)
const body = encodeURIComponent(`Hi Dat,\n\n${message}\n\nFrom: ${name}\nEmail: ${email}`)
window.location.href = `mailto:${personalInfo.email}?subject=${subject}&body=${body}`
}
const socialLinks = [
{
id: 'contact-github',
icon: GithubIcon,
label: 'GitHub',
href: personalInfo.githubUrl,
value: `github.com/${personalInfo.github}`,
},
{
id: 'contact-email',
icon: Mail,
label: 'Email',
href: `mailto:${personalInfo.email}`,
value: personalInfo.email,
},
{
id: 'contact-linkedin',
icon: LinkedinIcon,
label: 'LinkedIn',
href: 'https://linkedin.com',
value: 'linkedin.com/in/datld',
},
]
return (
<section
id="contact"
ref={sectionRef}
className="py-24"
aria-label="Contact section"
>
<section id="contact" ref={sectionRef} className="section-shell" aria-label="Contact section">
<div className="section-container">
{/* Section Heading */}
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
transition={{ duration: 0.45 }}
className="section-heading"
>
<p
className="muted-eyebrow mb-3"
style={{ fontFamily: 'var(--font-mono)' }}
>
05. Contact
</p>
<h2
className="section-title"
>
Let's talk
</h2>
<div
className="mt-3 h-1 w-16 rounded-full"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
<p className="section-kicker">Contact</p>
<h2 className="section-title">Let&apos;s talk.</h2>
</motion.div>
{/* Split Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
{/* Left — Message + Links */}
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.2 }}
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45, delay: 0.08 }}
className="card p-6"
>
<p
className="text-xl sm:text-2xl font-semibold mb-4 leading-snug"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)' }}
>
If you're looking for a backend developer who takes system design seriously,{' '}
<span className="gradient-text">I'd love to connect.</span>
</p>
<p className="text-sm mb-10" style={{ color: 'var(--color-muted2)' }}>
Open to internships, part-time roles, and collaboration.
<p className="text-base leading-8 text-[var(--muted-2)]">
Open to internships, junior backend roles, and project collaboration.
</p>
<div className="space-y-5">
{socialLinks.map((link, i) => (
<motion.a
key={link.id}
id={link.id}
href={link.href}
target={link.href.startsWith('http') ? '_blank' : undefined}
rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
initial={{ opacity: 0, x: -16 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.5, delay: 0.3 + i * 0.1 }}
className="flex items-center gap-4 p-4 rounded-xl transition-all duration-200 group"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border-subtle)',
}}
whileHover={{
borderColor: 'rgba(249,115,22,0.4)',
boxShadow: '0 0 16px rgba(249,115,22,0.15)',
x: 4,
}}
>
<div
className="flex items-center justify-center w-10 h-10 rounded-lg shrink-0"
style={{
background: 'rgba(249,115,22,0.1)',
border: '1px solid rgba(249,115,22,0.2)',
color: 'var(--color-primary)',
}}
>
<link.icon size={18} />
</div>
<div>
<div
className="text-xs uppercase tracking-widest mb-0.5"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
{link.label}
</div>
<div
className="text-sm font-medium group-hover:text-[var(--color-primary)] transition-colors"
style={{ color: 'var(--color-text)' }}
>
{link.value}
</div>
</div>
</motion.a>
))}
<div className="mt-6 space-y-3">
<a href={`mailto:${personalInfo.email}`} className="btn-secondary w-full justify-start">
<Mail size={18} />
{personalInfo.email}
</a>
<a
href={personalInfo.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-full justify-start"
>
<GithubIcon size={18} />
github.com/{personalInfo.github}
</a>
</div>
</motion.div>
{/* Right — Form */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.3 }}
<motion.form
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45, delay: 0.16 }}
onSubmit={handleSubmit}
className="card space-y-5 p-6"
aria-label="Contact form"
>
<form
onSubmit={handleSubmit}
className="rounded-2xl p-8 space-y-8"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border-subtle)',
}}
aria-label="Contact form"
>
<AnimatedInput
id="contact-name"
label="Your Name"
value={name}
onChange={setName}
/>
<AnimatedInput
id="contact-email"
label="Email Address"
type="email"
value={email}
onChange={setEmail}
/>
<AnimatedInput
id="contact-message"
label="Message"
value={message}
onChange={setMessage}
multiline
/>
<button
id="contact-submit"
type="submit"
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-[1.02] hover:shadow-lg active:scale-[0.98]"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
boxShadow: '0 0 20px rgba(249,115,22,0.25)',
fontFamily: 'var(--font-heading)',
}}
>
<Send size={16} />
Send Message
</button>
</form>
</motion.div>
<Field id="contact-name" label="Name" value={name} onChange={setName} />
<Field id="contact-email" label="Email" type="email" value={email} onChange={setEmail} />
<Field id="contact-message" label="Message" value={message} onChange={setMessage} multiline />
<button type="submit" className="btn-primary">
<Send size={18} />
Send Message
</button>
</motion.form>
</div>
</div>
</section>
......
'use client'
import { useRef } from 'react'
import { motion, useInView, useReducedMotion } from 'framer-motion'
import { timelineEvents, type TimelineEvent } from '@/lib/data'
import { fadeUp, fadeUpReduced } from '@/lib/animations'
const badgeStyles: Record<NonNullable<TimelineEvent['badgeColor']>, { bg: string; color: string; border: string }> = {
orange: { bg: 'rgba(249,115,22,0.12)', color: '#F97316', border: 'rgba(249,115,22,0.3)' },
blue: { bg: 'rgba(59,130,246,0.12)', color: '#60A5FA', border: 'rgba(59,130,246,0.3)' },
green: { bg: 'rgba(34,197,94,0.12)', color: '#4ADE80', border: 'rgba(34,197,94,0.3)' },
purple: { bg: 'rgba(168,85,247,0.12)', color: '#C084FC', border: 'rgba(168,85,247,0.3)' },
}
interface TimelineNodeProps {
event: TimelineEvent
index: number
isInView: boolean
isLast: boolean
prefersReduced: boolean | null
}
function TimelineNode({ event, index, isInView, isLast, prefersReduced }: TimelineNodeProps) {
const badgeStyle = event.badgeColor ? badgeStyles[event.badgeColor] : badgeStyles.orange
return (
<motion.div
initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -32 }}
animate={isInView
? prefersReduced ? { opacity: 1 } : { opacity: 1, x: 0 }
: {}
}
transition={{ duration: prefersReduced ? 0.3 : 0.55, delay: index * 0.15, ease: 'easeOut' }}
className="relative flex gap-6"
>
{/* Left — Line & Dot */}
<div className="relative flex flex-col items-center" style={{ minWidth: '40px' }}>
{/* Animated vertical connector */}
{!isLast && (
<motion.div
className="absolute top-10 bottom-0 w-[2px]"
initial={{ scaleY: 0, originY: 0 }}
animate={isInView ? { scaleY: 1 } : {}}
transition={{ duration: 0.8, delay: 0.2 + index * 0.15, ease: 'easeOut' }}
style={{
background: 'linear-gradient(to bottom, rgba(249,115,22,0.5), rgba(249,115,22,0.05))',
left: '50%',
transform: 'translateX(-50%)',
transformOrigin: 'top',
}}
/>
)}
{/* Dot */}
<motion.div
initial={{ scale: 0 }}
animate={isInView ? { scale: 1 } : {}}
transition={{ duration: 0.4, delay: 0.1 + index * 0.15, ease: 'backOut' }}
className="relative z-10 w-10 h-10 rounded-full flex items-center justify-center shrink-0"
style={{
background: 'var(--color-surface)',
border: '2px solid #F97316',
boxShadow: '0 0 12px rgba(249,115,22,0.3)',
}}
>
<span
className="w-3 h-3 rounded-full"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
/>
</motion.div>
</div>
{/* Right — Content card */}
<div
className={`flex-1 rounded-xl p-5 glass-card ${isLast ? 'mb-0' : 'mb-6'}`}
style={{ marginTop: '5px' }}
>
{/* Date + Badge */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<span
className="text-xs font-medium"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
{event.date}
</span>
{event.badge && (
<span
className="px-2.5 py-0.5 rounded-full text-xs font-semibold"
style={{
background: badgeStyle.bg,
color: badgeStyle.color,
border: `1px solid ${badgeStyle.border}`,
fontFamily: 'var(--font-mono)',
}}
>
{event.badge}
</span>
)}
</div>
<h3
className="font-semibold text-base mb-1"
style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)' }}
>
{event.title}
</h3>
{event.subtitle && (
<p className="text-sm mb-1" style={{ color: 'var(--primary)' }}>
{event.subtitle}
</p>
)}
<p className="text-sm" style={{ color: 'var(--color-muted2)' }}>
{event.description}
</p>
</div>
</motion.div>
)
}
import { motion, useInView } from 'framer-motion'
import { timelineEvents } from '@/lib/data'
export default function Experience() {
const sectionRef = useRef<HTMLElement>(null)
const isInView = useInView(sectionRef, { once: true, margin: '-80px' })
const prefersReduced = useReducedMotion()
const headingVariants = prefersReduced ? fadeUpReduced : fadeUp
return (
<section
id="experience"
ref={sectionRef}
className="py-24"
style={{ background: 'var(--color-surface)' }}
aria-label="Experience section"
>
<section id="experience" ref={sectionRef} className="section-shell" aria-label="Experience section">
<div className="section-container">
{/* Section Heading */}
<motion.div
variants={headingVariants}
custom={0}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="mb-16"
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45 }}
className="section-heading"
>
<p className="muted-eyebrow mb-3" style={{ fontFamily: 'var(--font-mono)' }}>
04. Experience
</p>
<h2 className="section-title">Where I&apos;ve been</h2>
<div
className="mt-3 h-1 w-16 rounded-full"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
<p className="section-kicker">Experience</p>
<h2 className="section-title">Recent milestones.</h2>
</motion.div>
{/* Timeline */}
<div className="max-w-3xl">
{timelineEvents.map((event, i) => (
<TimelineNode
<div className="grid gap-3">
{timelineEvents.map((event, index) => (
<motion.article
key={event.id}
event={event}
index={i}
isInView={isInView}
isLast={i === timelineEvents.length - 1}
prefersReduced={prefersReduced}
/>
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.38, delay: index * 0.05 }}
className="card grid gap-4 p-5 sm:grid-cols-[160px_1fr]"
>
<div>
<p className="text-sm font-bold text-[var(--primary)]">{event.date}</p>
{event.badge && (
<span className="mt-3 inline-flex rounded-full bg-[var(--surface-2)] px-3 py-1 text-xs font-bold text-[var(--muted)]">
{event.badge}
</span>
)}
</div>
<div>
<h3 className="text-lg">{event.title}</h3>
{event.subtitle && (
<p className="mt-1 text-sm font-semibold text-[var(--muted)]">{event.subtitle}</p>
)}
<p className="mt-3 text-sm leading-7 text-[var(--muted-2)]">{event.description}</p>
</div>
</motion.article>
))}
</div>
</div>
......
'use client'
import GithubIcon from '@/components/GithubIcon'
import { navLinks, personalInfo } from '@/lib/data'
const GithubIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
</svg>
)
export default function Footer() {
const handleNavClick = (href: string) => {
const id = href.slice(1)
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
document.getElementById(href.slice(1))?.scrollIntoView({ behavior: 'smooth' })
}
return (
<footer
className="py-10 border-t"
style={{
background: 'var(--color-surface)',
borderColor: 'rgba(255,255,255,0.06)',
}}
role="contentinfo"
>
<div className="section-container">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
{/* Copyright */}
<p
className="text-sm text-center sm:text-left"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
Built with Next.js · Ho Chi Minh City · 2026
</p>
<footer className="border-t border-[var(--border)] bg-white py-8" role="contentinfo">
<div className="section-container flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[var(--muted)]">
Built with Next.js and Tailwind CSS. Ho Chi Minh City, 2026.
</p>
{/* Nav Links */}
<nav className="flex items-center flex-wrap gap-6 justify-center" aria-label="Footer navigation">
{navLinks.map((link) => (
<button
key={link.href}
onClick={() => handleNavClick(link.href)}
className="text-xs uppercase tracking-widest transition-colors duration-200 hover:text-[var(--color-primary)]"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
{link.label}
</button>
))}
</nav>
<nav className="flex flex-wrap gap-3" aria-label="Footer navigation">
{navLinks.map((link) => (
<button
key={link.href}
type="button"
onClick={() => handleNavClick(link.href)}
className="text-sm font-semibold text-[var(--muted)] hover:text-[var(--primary)]"
>
{link.label}
</button>
))}
</nav>
{/* GitHub Icon */}
<a
href={personalInfo.githubUrl}
target="_blank"
rel="noopener noreferrer"
id="footer-github"
className="flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200 hover:scale-110 hover:text-[var(--color-primary)]"
style={{
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--color-muted)',
}}
aria-label="GitHub profile"
>
<GithubIcon size={18} />
</a>
</div>
<a
href={personalInfo.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-10 w-10 items-center justify-center rounded-md border border-[var(--border)] text-[var(--muted)] hover:text-[var(--primary)]"
aria-label="GitHub profile"
>
<GithubIcon size={18} />
</a>
</div>
</footer>
)
......
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>
)
}
'use client'
import { useEffect, useRef, useState } from 'react'
import { Mail, MapPin } from 'lucide-react'
import { motion, useReducedMotion } from 'framer-motion'
import { Mail, ChevronDown } from 'lucide-react'
import { useTypingEffect } from '@/hooks/useTypingEffect'
import GithubIcon from '@/components/GithubIcon'
import { fadeUp, fadeUpReduced } from '@/lib/animations'
import { personalInfo } from '@/lib/data'
// ─── SVG Brand Icons ──────────────────────────────────────────
const GithubIcon = ({ size = 18 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
)
export default function Hero() {
const prefersReduced = useReducedMotion()
const variants = prefersReduced ? fadeUpReduced : fadeUp
const { displayText, phase } = useTypingEffect()
return (
<section
id="hero"
className="relative flex items-center min-h-screen overflow-hidden"
aria-label="Hero section"
>
{/* Animated Dot-Grid Background */}
<div className="hero-bg pointer-events-none" aria-hidden="true" />
{/* Content */}
<div className="section-container relative z-10 w-full pt-24 pb-16">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
{/* Left — Staggered entrance */}
<div className="max-w-xl">
{/* i=0 Eyebrow */}
<motion.p
variants={variants}
custom={0}
initial="hidden"
animate="visible"
className="muted-eyebrow mb-3"
>
Hey, I&apos;m
</motion.p>
<section id="hero" className="section-shell pt-32 sm:pt-36" aria-label="Hero section">
<div className="section-container">
<div className="grid gap-8 lg:grid-cols-[1fr_360px] lg:items-end">
<motion.div
variants={variants}
custom={0}
initial="hidden"
animate="visible"
className="max-w-3xl"
>
<div className="pill px-3 py-2 mb-5">
<span className="h-2 w-2 rounded-full bg-[var(--accent)]" />
Open to internship and junior backend roles
</div>
{/* i=1 Name */}
<motion.h1
variants={variants}
custom={1}
initial="hidden"
animate="visible"
className="gradient-text hero-name mb-4 leading-none"
>
<h1 className="text-[clamp(2.55rem,8vw,5.1rem)] tracking-normal">
{personalInfo.name}
</motion.h1>
</h1>
{/* i=2 Typing role */}
<motion.div
variants={variants}
custom={2}
initial="hidden"
animate="visible"
className="flex items-center gap-1 mb-6 h-9"
>
<span
className="text-xl sm:text-2xl font-semibold"
style={{ color: 'var(--color-muted2)', fontFamily: 'var(--font-heading)' }}
>
{displayText}
</span>
{/* Blinking cursor */}
{!prefersReduced && (
<span
className="typing-cursor inline-block w-[2px] h-7 rounded-sm ml-0.5"
style={{ background: 'var(--primary)' }}
aria-hidden="true"
/>
)}
</motion.div>
<p className="mt-4 text-xl font-semibold text-[var(--primary)] sm:text-2xl">
{personalInfo.role}
</p>
{/* i=3 Short bio */}
<motion.p
variants={variants}
custom={3}
initial="hidden"
animate="visible"
className="text-base sm:text-lg mb-8 max-w-md"
style={{ color: 'var(--color-muted2)', lineHeight: 1.7 }}
>
{personalInfo.shortBio}
</motion.p>
<p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted-2)] sm:text-lg">
I build clean REST APIs, Spring Boot services, and database-backed systems with a focus on readability and steady growth.
</p>
{/* i=4 CTA buttons */}
<motion.div
variants={variants}
custom={4}
initial="hidden"
animate="visible"
className="flex flex-wrap gap-4 mb-10"
>
<a
href="#projects"
id="hero-view-projects"
onClick={(e) => {
e.preventDefault()
document.getElementById('projects')?.scrollIntoView({ behavior: 'smooth' })
}}
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-300 hover:scale-105 hover:shadow-lg"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
boxShadow: '0 0 20px rgba(249,115,22,0.3)',
fontFamily: 'var(--font-heading)',
}}
>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="#projects" className="btn-primary">
View Projects
</a>
<a
href={`mailto:${personalInfo.email}`}
id="hero-contact-link"
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl font-semibold text-sm border transition-all duration-300 hover:scale-105"
style={{
borderColor: 'var(--primary)',
color: 'var(--primary)',
background: 'transparent',
fontFamily: 'var(--font-heading)',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(249,115,22,0.1)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
>
Get in Touch
<a href={`mailto:${personalInfo.email}`} className="btn-secondary">
<Mail size={18} />
Contact Me
</a>
</motion.div>
</div>
</motion.div>
{/* i=5 Social icons + scroll arrow */}
<motion.div
variants={variants}
custom={5}
initial="hidden"
animate="visible"
className="flex items-center gap-4"
>
<motion.aside
variants={variants}
custom={1}
initial="hidden"
animate="visible"
className="card p-5"
aria-label="Profile summary"
>
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-lg bg-[var(--surface-2)] text-xl font-bold text-[var(--primary)]">
{personalInfo.initials}
</div>
<dl className="space-y-4 text-sm">
<div>
<dt className="font-semibold text-[var(--text)]">Focus</dt>
<dd className="mt-1 text-[var(--muted)]">Java, Spring Boot, REST APIs</dd>
</div>
<div>
<dt className="font-semibold text-[var(--text)]">Education</dt>
<dd className="mt-1 text-[var(--muted)]">Software Engineering at PTIT HCMC</dd>
</div>
<div>
<dt className="font-semibold text-[var(--text)]">Location</dt>
<dd className="mt-1 flex items-center gap-2 text-[var(--muted)]">
<MapPin size={16} />
Ho Chi Minh City
</dd>
</div>
</dl>
<div className="mt-6 flex gap-3 border-t border-[var(--border)] pt-5">
<a
href={personalInfo.githubUrl}
id="hero-github-link"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm transition-all duration-200 group"
style={{ color: 'var(--color-muted)' }}
className="btn-secondary min-h-10 flex-1 px-3 py-2"
aria-label="GitHub profile"
>
<GithubIcon size={18} />
<span className="group-hover:text-[var(--color-text)] transition-colors">
github.com/{personalInfo.github}
</span>
<GithubIcon size={17} />
GitHub
</a>
<span style={{ color: 'var(--color-border-subtle)' }}>·</span>
<a
href={`mailto:${personalInfo.email}`}
id="hero-email-link"
className="flex items-center gap-2 text-sm transition-all duration-200 group"
style={{ color: 'var(--color-muted)' }}
aria-label="Email contact"
className="btn-secondary min-h-10 flex-1 px-3 py-2"
aria-label="Email"
>
<Mail size={18} className="group-hover:text-[var(--primary)] transition-colors" />
<span className="group-hover:text-[var(--color-text)] transition-colors">
{personalInfo.email}
</span>
<Mail size={17} />
Email
</a>
</motion.div>
</div>
{/* Right — Animated Terminal */}
<motion.div
initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: 40 }}
animate={prefersReduced ? { opacity: 1 } : { opacity: 1, x: 0 }}
transition={{ duration: 0.8, delay: 0.9, ease: 'easeOut' }}
className="hidden lg:flex justify-end"
>
<div className="terminal-float">
<TerminalWidget />
</div>
</motion.div>
</motion.aside>
</div>
</div>
{/* Scroll-down Arrow */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.6, duration: 0.6 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 flex flex-col items-center gap-1"
>
<span
className="text-xs tracking-widest uppercase"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
scroll
</span>
<ChevronDown
size={20}
className="scroll-bounce"
style={{ color: 'var(--primary)' }}
/>
</motion.div>
</section>
)
}
// ─── Terminal Widget — lines appear one by one ────────────────
function TerminalWidget() {
const lines = [
{ prefix: '$ ', text: 'java -jar portfolio.jar', color: 'var(--color-text)' },
{ prefix: '> ', text: 'Initializing Spring context...', color: 'var(--color-muted)' },
{ prefix: '> ', text: 'Loading beans: 42 definitions', color: 'var(--color-muted)' },
{ prefix: '> ', text: 'Server started on :8080', color: 'var(--color-muted)' },
{ prefix: '> ', text: 'Status: UP ✓', color: '#22c55e' },
]
const [visibleCount, setVisibleCount] = useState(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
function showNext(idx: number) {
if (idx >= lines.length) return
timerRef.current = setTimeout(() => {
setVisibleCount(idx + 1)
showNext(idx + 1)
}, 600)
}
// start after a short delay so the widget entrance animation completes
timerRef.current = setTimeout(() => showNext(0), 300)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const allDone = visibleCount >= lines.length
return (
<div
className="rounded-xl overflow-hidden shadow-2xl w-80"
style={{ border: '1px solid rgba(249,115,22,0.25)', boxShadow: '0 0 40px rgba(249,115,22,0.1)' }}
>
{/* Title bar */}
<div
className="flex items-center gap-2 px-4 py-3"
style={{ background: '#1A1A2E', borderBottom: '1px solid rgba(255,255,255,0.06)' }}
>
<span className="w-3 h-3 rounded-full" style={{ background: '#FF5F57' }} />
<span className="w-3 h-3 rounded-full" style={{ background: '#FEBC2E' }} />
<span className="w-3 h-3 rounded-full" style={{ background: '#28C840' }} />
<span
className="ml-2 text-xs"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
terminal
</span>
</div>
{/* Body */}
<div
className="px-4 py-4 space-y-2"
style={{ background: '#0D1117', fontFamily: 'var(--font-mono)', fontSize: '0.78rem', minHeight: '8rem' }}
>
{lines.slice(0, visibleCount).map((line, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.25 }}
className="flex items-start gap-1"
>
<span style={{ color: 'var(--primary)' }}>{line.prefix}</span>
<span style={{ color: line.color }}>{line.text}</span>
</motion.div>
))}
{/* Blinking cursor — shown after all lines or while typing */}
<div className="flex items-center gap-1">
<span style={{ color: 'var(--primary)' }}>$ </span>
{allDone && (
<span
className="typing-cursor inline-block w-2 h-4 rounded-sm"
style={{ background: 'var(--primary)' }}
aria-hidden="true"
/>
)}
</div>
</div>
</div>
)
}
'use client'
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { useCallback, useEffect, useState } from 'react'
import { Menu, X } from 'lucide-react'
import { navLinks, personalInfo } from '@/lib/data'
// ─── Active Section via Intersection Observer ────────────────
function useActiveSection(sectionIds: string[]): string {
const [active, setActive] = useState<string>('')
const [active, setActive] = useState('')
useEffect(() => {
const observers: IntersectionObserver[] = []
......@@ -20,26 +18,27 @@ function useActiveSection(sectionIds: string[]): string {
([entry]) => {
if (entry.isIntersecting) setActive(id)
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 }
{ rootMargin: '-38% 0px -55% 0px', threshold: 0 }
)
observer.observe(el)
observers.push(observer)
})
return () => observers.forEach((o) => o.disconnect())
return () => observers.forEach((observer) => observer.disconnect())
}, [sectionIds])
return active
}
export default function Navbar() {
const [scrolled, setScrolled] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const sectionIds = navLinks.map((l) => l.href.slice(1))
const [scrolled, setScrolled] = useState(false)
const sectionIds = navLinks.map((link) => link.href.slice(1))
const active = useActiveSection(sectionIds)
const handleScroll = useCallback(() => {
setScrolled(window.scrollY > 60)
setScrolled(window.scrollY > 24)
}, [])
useEffect(() => {
......@@ -49,128 +48,79 @@ export default function Navbar() {
const handleNavClick = (href: string) => {
setMobileOpen(false)
const id = href.slice(1)
const el = document.getElementById(id)
el?.scrollIntoView({ behavior: 'smooth' })
document.getElementById(href.slice(1))?.scrollIntoView({ behavior: 'smooth' })
}
return (
<>
<motion.nav
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className={`fixed top-0 left-0 right-0 z-50 ${
scrolled ? 'scrolled backdrop-blur-md border-b border-white/5' : 'bg-transparent'
}`}
style={{
backgroundColor: scrolled ? 'rgba(10,15,30,0.92)' : 'transparent',
}}
>
<div className="section-container">
<div className="flex items-center justify-between h-16">
{/* Logo Badge */}
<a
href="#"
onClick={(e) => { e.preventDefault(); window.scrollTo({ top: 0, behavior: 'smooth' }) }}
className="flex items-center gap-2"
aria-label="Back to top"
>
<span
className="inline-flex items-center justify-center w-10 h-10 rounded-xl font-bold text-sm tracking-widest"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
fontFamily: 'var(--font-heading)',
boxShadow: '0 0 16px rgba(249,115,22,0.4)',
}}
<header
className={`fixed inset-x-0 top-0 z-50 border-b transition-colors ${
scrolled ? 'border-[var(--border)] bg-white/92 backdrop-blur' : 'border-transparent bg-white/70 backdrop-blur'
}`}
>
<nav className="section-container flex h-16 items-center justify-between" aria-label="Main navigation">
<a
href="#hero"
onClick={(event) => {
event.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="flex items-center gap-3 font-semibold text-[var(--text)]"
aria-label="Back to top"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)] text-sm font-bold text-white">
{personalInfo.initials}
</span>
<span>{personalInfo.name}</span>
</a>
<ul className="hidden items-center gap-1 md:flex">
{navLinks.map((link) => {
const sectionId = link.href.slice(1)
const isActive = active === sectionId
return (
<li key={link.href}>
<button
onClick={() => handleNavClick(link.href)}
className={`rounded-md px-3 py-2 text-sm font-semibold transition-colors ${
isActive
? 'bg-[var(--surface-2)] text-[var(--primary)]'
: 'text-[var(--muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]'
}`}
>
{link.label}
</button>
</li>
)
})}
</ul>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-md border border-[var(--border)] bg-white text-[var(--text)] md:hidden"
onClick={() => setMobileOpen((value) => !value)}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileOpen}
>
{mobileOpen ? <X size={19} /> : <Menu size={19} />}
</button>
</nav>
{mobileOpen && (
<div className="border-t border-[var(--border)] bg-white md:hidden">
<div className="section-container py-2">
{navLinks.map((link) => (
<button
key={link.href}
onClick={() => handleNavClick(link.href)}
className="block w-full rounded-md px-3 py-3 text-left text-sm font-semibold text-[var(--muted)] hover:bg-[var(--surface-2)] hover:text-[var(--text)]"
>
{personalInfo.initials}
</span>
</a>
{/* Desktop Nav Links */}
<ul className="hidden md:flex items-center gap-8" role="navigation">
{navLinks.map((link) => {
const sectionId = link.href.slice(1)
const isActive = active === sectionId
return (
<li key={link.href}>
<button
onClick={() => handleNavClick(link.href)}
className={`relative text-sm font-medium transition-colors duration-200 group pb-1 ${
isActive
? 'text-[var(--color-primary)]'
: 'text-[var(--color-muted2)] hover:text-[var(--color-text)]'
}`}
>
{link.label}
{/* Orange underline */}
<span
className={`absolute bottom-0 left-0 h-[2px] rounded-full transition-all duration-300 ${
isActive ? 'w-full' : 'w-0 group-hover:w-full'
}`}
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
</button>
</li>
)
})}
</ul>
{/* Mobile Hamburger */}
<button
className="md:hidden flex items-center justify-center w-10 h-10 rounded-lg text-[var(--color-muted2)] hover:text-[var(--color-primary)] transition-colors"
onClick={() => setMobileOpen((v) => !v)}
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
aria-expanded={mobileOpen}
>
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
</button>
{link.label}
</button>
))}
</div>
</div>
</motion.nav>
{/* Mobile Menu Dropdown */}
<AnimatePresence>
{mobileOpen && (
<motion.div
key="mobile-menu"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
className="fixed top-16 left-0 right-0 z-40 border-b border-white/5"
style={{ backgroundColor: 'rgba(10,15,30,0.97)', backdropFilter: 'blur(16px)' }}
>
<ul className="section-container py-4 flex flex-col gap-1" role="navigation">
{navLinks.map((link, i) => {
const sectionId = link.href.slice(1)
const isActive = active === sectionId
return (
<motion.li
key={link.href}
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<button
onClick={() => handleNavClick(link.href)}
className={`w-full text-left px-4 py-3 rounded-lg text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-[var(--color-primary-dim)] text-[var(--color-primary)]'
: 'text-[var(--color-muted2)] hover:bg-white/5 hover:text-[var(--color-text)]'
}`}
>
{link.label}
</button>
</motion.li>
)
})}
</ul>
</motion.div>
)}
</AnimatePresence>
</>
)}
</header>
)
}
'use client'
import { motion } from 'framer-motion'
import GithubIcon from '@/components/GithubIcon'
import { type Project } from '@/lib/data'
const GithubIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
</svg>
)
function TechBadge({ label }: { label: string }) {
return (
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{
background: 'rgba(249,115,22,0.1)',
color: 'var(--color-accent)',
border: '1px solid rgba(249,115,22,0.2)',
fontFamily: 'var(--font-mono)',
}}
>
{label}
</span>
)
}
function JsonPreview() {
return (
<div
className="rounded-lg overflow-hidden text-xs"
style={{
background: '#0D1117',
border: '1px solid rgba(249,115,22,0.15)',
fontFamily: 'var(--font-mono)',
}}
>
<div
className="flex items-center gap-2 px-3 py-2 text-xs"
style={{ background: '#161B22', borderBottom: '1px solid rgba(255,255,255,0.05)', color: 'var(--color-muted)' }}
>
<span style={{ color: '#22c55e' }}>GET</span>
<span style={{ color: 'var(--color-muted2)' }}>/api/orders/123</span>
<span className="ml-auto" style={{ color: '#22c55e' }}>200 OK</span>
</div>
<div className="px-4 py-3 space-y-0.5" style={{ lineHeight: 1.7 }}>
<div style={{ color: '#E6EDF3' }}>{`{`}</div>
<JsonLine k="orderId" v="123" vColor="#79C0FF" />
<JsonLine k="status" v={`"CONFIRMED"`} vColor="#A5D6FF" />
<JsonLine k="userId" v="42" vColor="#79C0FF" />
<JsonLine k="total" v="250000" vColor="#79C0FF" />
<JsonLine k="createdAt" v={`"2026-03-15T10:30:00Z"`} vColor="#A5D6FF" />
<div style={{ color: '#E6EDF3' }}>{`}`}</div>
</div>
</div>
)
}
function JsonLine({ k, v, vColor }: { k: string; v: string; vColor: string }) {
return (
<div className="pl-4">
<span style={{ color: '#7EE787' }}>&quot;{k}&quot;</span>
<span style={{ color: '#E6EDF3' }}>: </span>
<span style={{ color: vColor }}>{v}</span>
<span style={{ color: '#E6EDF3' }}>,</span>
</div>
)
}
export interface ProjectCardProps {
project: Project
index: number
isInView: boolean
large?: boolean
tall?: boolean
}
export default function ProjectCard({ project, index, isInView, large = false, tall = false }: ProjectCardProps) {
export default function ProjectCard({ project, index, isInView }: ProjectCardProps) {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
<motion.article
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: index * 0.1 }}
className={`project-card group relative rounded-2xl overflow-hidden flex flex-col ${
large ? 'bento-featured' : tall ? 'bento-tall' : ''
}`}
style={{
minHeight: tall ? '420px' : 'auto',
}}
transition={{ duration: 0.4, delay: index * 0.06 }}
className="card flex h-full flex-col p-5"
>
<div
aria-hidden="true"
className="absolute top-0 left-0 right-0 h-[2px] rounded-t-2xl"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 pr-4">
<h3
className={`font-bold mb-1 ${large ? 'text-xl' : 'text-base'} group-hover:text-[var(--color-primary)] transition-colors`}
style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)' }}
>
{project.name}
</h3>
{project.role && (
<p
className="text-xs mb-1"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
{project.role}
</p>
)}
</div>
<span
className="shrink-0 px-2 py-1 rounded-md text-xs font-medium"
style={{
background: 'rgba(255,255,255,0.05)',
color: 'var(--color-muted)',
border: '1px solid rgba(255,255,255,0.08)',
fontFamily: 'var(--font-mono)',
}}
>
{project.period.split('–')[0].trim()}
</span>
</div>
<p
className={`leading-relaxed flex-1 mb-5 ${large ? 'text-sm' : 'text-sm'}`}
style={{ color: 'var(--color-muted2)' }}
>
{project.description}
</p>
{project.variant === 'featured' && (
<div className="mb-5">
<JsonPreview />
</div>
)}
<div className="flex items-end justify-between gap-3 mt-auto">
<div className="flex flex-wrap gap-1.5">
{project.stack.slice(0, large ? project.stack.length : 3).map((tech) => (
<TechBadge key={tech} label={tech} />
))}
{!large && project.stack.length > 3 && (
<span
className="px-2 py-0.5 rounded text-xs"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
+{project.stack.length - 3}
</span>
)}
</div>
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
id={`project-github-${project.id}`}
className="shrink-0 flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200 hover:scale-110"
style={{
background: 'rgba(249,115,22,0.1)',
border: '1px solid rgba(249,115,22,0.25)',
color: 'var(--color-primary)',
}}
aria-label={`GitHub — ${project.name}`}
>
<GithubIcon size={16} />
</a>
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<p className="mb-2 text-sm font-bold text-[var(--primary)]">{project.period}</p>
<h3 className="text-xl">{project.name}</h3>
{project.role && (
<p className="mt-2 text-sm font-semibold text-[var(--muted)]">{project.role}</p>
)}
</div>
</div>
<div
className="absolute bottom-0 left-0 right-0 flex items-center justify-center gap-3 py-4 translate-y-full group-hover:translate-y-0 transition-transform duration-300"
style={{ background: 'linear-gradient(to top, rgba(249,115,22,0.15), transparent)' }}
>
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold transition-all hover:scale-105"
style={{
background: 'linear-gradient(135deg, #F97316, #FB923C)',
color: '#0A0F1E',
fontFamily: 'var(--font-heading)',
}}
aria-label={`View ${project.name} on GitHub`}
>
<GithubIcon size={14} />
View on GitHub
</a>
)}
<p className="mb-5 flex-1 text-sm leading-7 text-[var(--muted-2)]">{project.description}</p>
<div className="mb-5 flex flex-wrap gap-2">
{project.stack.slice(0, 6).map((tech) => (
<span key={tech} className="tag">
{tech}
</span>
))}
</div>
</motion.div>
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-bold text-[var(--primary)] hover:text-[var(--primary-dark)]"
aria-label={`View ${project.name} on GitHub`}
>
<GithubIcon size={17} />
View source
</a>
)}
</motion.article>
)
}
'use client'
import { useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
import { useRef } from 'react'
import { motion, useInView } from 'framer-motion'
import { ExternalLink } from 'lucide-react'
import { projects } from '@/lib/data'
import SkeletonGrid from '@/components/SkeletonCard'
const ProjectCard = dynamic(() => import('@/components/ProjectCard'), {
loading: () => <div className="h-full w-full min-h-[260px] bg-[var(--surface)] animate-pulse rounded-xl" />,
})
const GithubIcon = ({ size = 16 }: { size?: number }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
</svg>
)
import GithubIcon from '@/components/GithubIcon'
import ProjectCard from '@/components/ProjectCard'
import { personalInfo, projects } from '@/lib/data'
export default function Projects() {
const sectionRef = useRef<HTMLElement>(null)
const isInView = useInView(sectionRef, { once: true, margin: '-80px' })
const featured = projects.find((p) => p.variant === 'featured')!
const card2 = projects.find((p) => p.variant === 'card2')!
const card3 = projects.find((p) => p.variant === 'card3')!
const card4 = projects.find((p) => p.variant === 'card4')!
return (
<section
id="projects"
ref={sectionRef}
className="py-24"
aria-label="Projects section"
>
<section id="projects" ref={sectionRef} className="section-shell" aria-label="Projects section">
<div className="section-container">
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
transition={{ duration: 0.45 }}
className="section-heading flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"
>
<p
className="muted-eyebrow mb-3"
style={{ fontFamily: 'var(--font-mono)' }}
>
03. Projects
</p>
<h2 className="section-title">
Things I&apos;ve built
</h2>
<div
className="mt-3 h-1 w-16 rounded-full"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
</motion.div>
<Suspense fallback={<SkeletonGrid />}>
<div className="bento-grid">
<div
className="bento-featured"
style={{ gridColumn: 'span 2', gridRow: 'span 1' }}
>
<ProjectCard project={featured} index={0} isInView={isInView} large />
</div>
<div
className="bento-card2"
style={{ gridColumn: '3', gridRow: 'span 2' }}
>
<ProjectCard project={card2} index={1} isInView={isInView} tall />
</div>
<div className="bento-card3">
<ProjectCard project={card3} index={2} isInView={isInView} />
</div>
<div className="bento-card4">
<ProjectCard project={card4} index={3} isInView={isInView} />
</div>
<div>
<p className="section-kicker">Projects</p>
<h2 className="section-title">Selected backend work.</h2>
</div>
</Suspense>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-10 flex justify-center"
>
<a
href="https://github.com/datdat054"
href={personalInfo.githubUrl}
target="_blank"
rel="noopener noreferrer"
id="projects-github-all"
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold transition-all duration-200 hover:scale-105"
style={{
background: 'rgba(249,115,22,0.1)',
color: 'var(--color-primary)',
border: '1px solid rgba(249,115,22,0.3)',
fontFamily: 'var(--font-heading)',
}}
className="btn-secondary w-fit"
>
<GithubIcon size={16} />
View All on GitHub
<ExternalLink size={14} />
<GithubIcon size={18} />
GitHub
</a>
</motion.div>
<div className="grid gap-4 md:grid-cols-2">
{projects.map((project, index) => (
<ProjectCard key={project.id} project={project} index={index} isInView={isInView} />
))}
</div>
</div>
</section>
)
......
'use client'
export function SkeletonCard({ large = false, tall = false }: { large?: boolean; tall?: boolean }) {
export function SkeletonCard() {
return (
<div
className="animate-pulse rounded-[12px] p-6 flex flex-col justify-between"
style={{
background: 'var(--surface)',
minHeight: tall ? '420px' : large ? '320px' : '260px',
border: '1px solid rgba(255,255,255,0.05)',
}}
>
<div className="card flex min-h-[240px] animate-pulse flex-col justify-between p-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="h-6 w-1/3 bg-white/10 rounded" />
<div className="h-4 w-12 bg-white/10 rounded" />
</div>
<div className="h-4 w-3/4 bg-white/10 rounded" />
<div className="h-4 w-1/2 bg-white/10 rounded" />
<div className="h-5 w-1/3 rounded bg-[var(--surface-2)]" />
<div className="h-7 w-2/3 rounded bg-[var(--surface-2)]" />
<div className="h-4 w-full rounded bg-[var(--surface-2)]" />
<div className="h-4 w-4/5 rounded bg-[var(--surface-2)]" />
</div>
<div className="flex gap-2 pt-6">
<div className="h-6 w-16 bg-white/10 rounded" />
<div className="h-6 w-20 bg-white/10 rounded" />
<div className="h-6 w-14 bg-white/10 rounded" />
<div className="h-7 w-16 rounded-full bg-[var(--surface-2)]" />
<div className="h-7 w-20 rounded-full bg-[var(--surface-2)]" />
<div className="h-7 w-14 rounded-full bg-[var(--surface-2)]" />
</div>
</div>
)
......@@ -29,19 +20,11 @@ export function SkeletonCard({ large = false, tall = false }: { large?: boolean;
export default function SkeletonGrid() {
return (
<div className="bento-grid">
<div className="bento-featured" style={{ gridColumn: 'span 2', gridRow: 'span 1' }}>
<SkeletonCard large />
</div>
<div className="bento-card2" style={{ gridColumn: '3', gridRow: 'span 2' }}>
<SkeletonCard tall />
</div>
<div className="bento-card3">
<SkeletonCard />
</div>
<div className="bento-card4">
<SkeletonCard />
</div>
<div className="grid gap-4 md:grid-cols-2">
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</div>
)
}
'use client'
import { useRef, useState } from 'react'
import { motion, AnimatePresence, useInView, useReducedMotion } from 'framer-motion'
import { fadeUp, fadeUpReduced } from '@/lib/animations'
import { skillCategories, type SkillTab, type Skill } from '@/lib/data'
import { motion, useInView, useReducedMotion } from 'framer-motion'
import { skillCategories, type SkillTab } from '@/lib/data'
// ─── Skill Progress Bar ───────────────────────────────────────
function SkillBar({
skill,
trigger,
index,
prefersReduced,
}: {
skill: Skill
trigger: boolean
index: number
prefersReduced: boolean | null
}) {
const variants = prefersReduced ? fadeUpReduced : fadeUp
return (
<motion.div
variants={variants}
custom={index}
initial="hidden"
animate={trigger ? 'visible' : 'hidden'}
className="skill-card group p-4"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: 'linear-gradient(135deg, #F97316, #FB923C)' }}
/>
<span
className="text-sm font-medium"
style={{ color: 'var(--color-text)', fontFamily: 'var(--font-body)' }}
>
{skill.name}
</span>
</div>
<span
className="text-xs font-semibold tabular-nums"
style={{ color: 'var(--primary)', fontFamily: 'var(--font-mono)' }}
>
{skill.level}%
</span>
</div>
<div className="progress-track">
<motion.div
className="progress-fill"
initial={{ width: 0 }}
animate={trigger ? { width: `${skill.level}%` } : { width: 0 }}
transition={{
duration: prefersReduced ? 0 : 1.1,
delay: prefersReduced ? 0 : index * 0.08,
ease: [0.25, 0.46, 0.45, 0.94],
}}
/>
</div>
</motion.div>
)
}
// ─── Skills Section ───────────────────────────────────────────
export default function Skills() {
const [activeTab, setActiveTab] = useState<SkillTab>('Backend')
const sectionRef = useRef<HTMLElement>(null)
const isInView = useInView(sectionRef, { once: true, margin: '-100px' })
const isInView = useInView(sectionRef, { once: true, margin: '-80px' })
const prefersReduced = useReducedMotion()
const currentCategory = skillCategories.find((c) => c.tab === activeTab)!
const tabs: SkillTab[] = ['Backend', 'Frontend', 'DevOps', 'Database']
const headingVariants = prefersReduced ? fadeUpReduced : fadeUp
const currentCategory = skillCategories.find((category) => category.tab === activeTab)!
return (
<section
id="skills"
ref={sectionRef}
className="py-24"
style={{ background: 'var(--color-surface)' }}
aria-label="Skills section"
>
<section id="skills" ref={sectionRef} className="section-shell" aria-label="Skills section">
<div className="section-container">
{/* Section Heading */}
<motion.div
variants={headingVariants}
custom={0}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="mb-16"
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45 }}
className="section-heading"
>
<p className="muted-eyebrow mb-3" style={{ fontFamily: 'var(--font-mono)' }}>
02. Skills
</p>
<h2 className="section-title">What I work with</h2>
<div
className="mt-3 h-1 w-16 rounded-full"
style={{ background: 'linear-gradient(90deg, #F97316, #FB923C)' }}
/>
<p className="section-kicker">Skills</p>
<h2 className="section-title">Tools I use most often.</h2>
</motion.div>
{/* Tab Switcher */}
<motion.div
variants={headingVariants}
custom={1}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="flex flex-wrap gap-2 mb-10"
initial={{ opacity: 0, y: 18 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.45, delay: 0.08 }}
className="mb-6 flex flex-wrap gap-2"
role="tablist"
aria-label="Skill categories"
>
{tabs.map((tab) => (
<button
key={tab}
type="button"
role="tab"
id={`skills-tab-${tab.toLowerCase()}`}
aria-selected={activeTab === tab}
onClick={() => setActiveTab(tab)}
className="relative px-5 py-2 rounded-full text-sm font-semibold transition-all duration-200 focus:outline-none"
style={{
background: activeTab === tab ? 'linear-gradient(135deg, #F97316, #FB923C)' : 'rgba(255,255,255,0.05)',
color: activeTab === tab ? '#0A0F1E' : 'var(--color-muted2)',
border: activeTab === tab ? 'none' : '1px solid rgba(255,255,255,0.08)',
fontFamily: 'var(--font-heading)',
boxShadow: activeTab === tab ? '0 0 16px rgba(249,115,22,0.35)' : 'none',
}}
className={`rounded-md border px-4 py-2 text-sm font-bold transition-colors ${
activeTab === tab
? 'border-[var(--primary)] bg-[var(--primary)] text-white'
: 'border-[var(--border)] bg-white text-[var(--muted)] hover:text-[var(--text)]'
}`}
>
{tab}
</button>
))}
</motion.div>
{/* Skills Grid — AnimatePresence tab slide */}
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={prefersReduced ? { opacity: 0 } : { opacity: 0, x: 16 }}
animate={prefersReduced ? { opacity: 1 } : { opacity: 1, x: 0 }}
exit={prefersReduced ? { opacity: 0 } : { opacity: 0, x: -16 }}
transition={{ duration: prefersReduced ? 0.2 : 0.22 }}
role="tabpanel"
aria-labelledby={`skills-tab-${activeTab.toLowerCase()}`}
className="grid grid-cols-1 sm:grid-cols-2 gap-x-12 gap-y-6"
>
{currentCategory.skills.map((skill, i) => (
<SkillBar
key={`${activeTab}-${skill.name}`}
skill={skill}
trigger={isInView}
index={i}
prefersReduced={prefersReduced}
/>
))}
</motion.div>
</AnimatePresence>
{/* Languages */}
<motion.div
variants={headingVariants}
custom={2}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="mt-16 pt-10 border-t"
style={{ borderColor: 'rgba(255,255,255,0.06)' }}
>
<p
className="text-xs uppercase tracking-widest mb-5 font-medium"
style={{ color: 'var(--color-muted)', fontFamily: 'var(--font-mono)' }}
>
Languages
</p>
<div className="flex flex-wrap gap-3">
{['Java', 'SQL', 'JavaScript', 'Python', 'C++'].map((lang) => (
<span
key={lang}
className="px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-200 hover:scale-105"
style={{
background: 'var(--color-surface2)',
color: 'var(--color-text)',
border: '1px solid var(--color-border-subtle)',
fontFamily: 'var(--font-mono)',
}}
>
{lang}
</span>
))}
</div>
</motion.div>
<div className="grid gap-4 sm:grid-cols-2">
{currentCategory.skills.map((skill, index) => (
<motion.div
key={`${activeTab}-${skill.name}`}
initial={{ opacity: 0, y: prefersReduced ? 0 : 14 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.35, delay: index * 0.03 }}
className="card p-4"
>
<div className="mb-3 flex items-center justify-between gap-4">
<span className="font-semibold text-[var(--text)]">{skill.name}</span>
<span className="text-sm font-bold text-[var(--primary)]">{skill.level}%</span>
</div>
<div className="progress-track">
<motion.div
className="progress-fill"
initial={{ width: 0 }}
animate={isInView ? { width: `${skill.level}%` } : { width: 0 }}
transition={{ duration: prefersReduced ? 0 : 0.8, delay: index * 0.03 }}
/>
</div>
</motion.div>
))}
</div>
</div>
</section>
)
......
......@@ -9,9 +9,12 @@ function ProgressBarContent() {
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
const start = setTimeout(() => setLoading(true), 0)
const timer = setTimeout(() => setLoading(false), 500)
return () => clearTimeout(timer)
return () => {
clearTimeout(start)
clearTimeout(timer)
}
}, [pathname, searchParams])
if (!loading) return null
......@@ -20,8 +23,8 @@ function ProgressBarContent() {
<div
className="fixed top-0 left-0 right-0 h-0.5 z-[10000] animate-pulse transition-all duration-300"
style={{
background: 'linear-gradient(90deg, #F97316, #FB923C)',
boxShadow: '0 0 10px #F97316',
background: 'var(--primary)',
boxShadow: '0 0 10px rgba(37, 99, 235, 0.35)',
}}
/>
)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment