Commit b53436f5 authored by DatLD's avatar DatLD

Finalize portfolio for production

parents
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
@AGENTS.md
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@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);
--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);
}
/* ============================================================
Base Reset & Global Styles
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.75;
min-height: 100vh;
overflow-x: hidden;
}
/* ============================================================
Typography Defaults
============================================================ */
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading);
font-weight: 700;
line-height: 1.2;
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);
}
p {
color: var(--color-muted2);
line-height: 1.7;
}
code, pre, kbd {
font-family: var(--font-mono);
}
a {
color: inherit;
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);
}
/* ============================================================
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; }
}
.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); }
}
.terminal-float {
animation: terminalFloat 4s ease-in-out infinite;
}
/* ============================================================
Blinking Cursor
============================================================ */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.cursor-blink {
animation: blink 1s step-end infinite;
}
/* Typing cursor — orange, 0.7s step-end */
.typing-cursor {
animation: blink 0.7s step-end infinite;
}
/* ============================================================
Navbar — Scrolled State
============================================================ */
nav {
transition: background-color 0.3s ease, backdrop-filter 0.3s ease;
}
nav.scrolled {
background: rgba(10, 15, 30, 0.92) !important;
backdrop-filter: blur(16px) !important;
-webkit-backdrop-filter: blur(16px) !important;
}
/* ============================================================
Scroll-down Bounce
============================================================ */
@keyframes scrollBounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(8px); }
}
.scroll-bounce {
animation: scrollBounce 1.5s ease-in-out infinite;
}
/* ============================================================
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;
}
/* ============================================================
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;
}
@keyframes shimmer {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
/* ============================================================
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);
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);
}
.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;
}
.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);
}
/* ============================================================
Progress Bar Base
============================================================ */
.progress-track {
background: var(--surface-2);
height: 6px;
border-radius: 3px;
position: relative;
overflow: hidden;
}
.progress-fill {
background: linear-gradient(90deg, #F97316, #FB923C);
border-radius: 3px;
height: 100%;
}
/* ============================================================
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; }
}
@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;
}
}
/* ============================================================
Accessibility & Polish
============================================================ */
html {
scroll-behavior: smooth;
scroll-padding-top: 80px;
}
*:focus-visible {
outline: 2px solid #F97316 !important;
outline-offset: 2px !important;
}
import type { Metadata } from 'next'
import { Inter, Space_Grotesk, JetBrains_Mono } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
const spaceGrotesk = Space_Grotesk({ subsets: ['latin'], variable: '--font-space' })
const jetbrainsMono = JetBrains_Mono({ subsets: ['latin'], variable: '--font-mono' })
export const metadata: Metadata = {
metadataBase: new URL('https://ducdat.dev'),
title: 'Luu Duc Dat — Java Backend Developer',
description:
'Portfolio of Luu Duc Dat, a Java Backend Developer specializing in Spring Boot, microservices, and PostgreSQL. Software Engineering student at PTIT HCMC.',
keywords: [
'Java developer',
'Spring Boot',
'backend developer',
'microservices',
'PTIT HCMC',
'Ho Chi Minh City',
],
authors: [{ name: 'Luu Duc Dat' }],
openGraph: {
title: 'Luu Duc Dat — Java Backend Developer',
description: 'Spring Boot engineer building scalable backend systems. PTIT HCMC.',
type: 'website',
url: 'https://ducdat.dev',
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'Luu Duc Dat Portfolio' }],
},
twitter: {
card: 'summary_large_image',
title: 'Luu Duc Dat — Java Backend Developer',
description: 'Spring Boot engineer. PTIT HCMC.',
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang="en"
className={`${spaceGrotesk.variable} ${inter.variable} ${jetbrainsMono.variable}`}
>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
</head>
<body className="min-h-screen antialiased">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-[9999] focus:px-4 focus:py-2 focus:rounded-md focus:bg-[var(--primary)] focus:text-[#0A0F1E] font-semibold shadow-lg"
>
Skip to content
</a>
{children}
</body>
</html>
)
}
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)' }}
>
404
</h1>
<p
className="text-2xl font-semibold mb-2"
style={{ color: 'var(--color-text)', fontFamily: 'var(--font-heading)' }}
>
Page not found.
</p>
<p className="text-sm mb-8 max-w-md" style={{ color: 'var(--color-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)',
}}
>
Back to Home
</Link>
</div>
)
}
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
import About from '@/components/About'
import Skills from '@/components/Skills'
import Projects from '@/components/Projects'
import Experience from '@/components/Experience'
import Contact from '@/components/Contact'
import Footer from '@/components/Footer'
import BackToTop from '@/components/BackToTop'
import TopProgressBar from '@/components/TopProgressBar'
export default function Home() {
return (
<>
<TopProgressBar />
<Navbar />
<main id="main-content">
<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 />
<BackToTop />
</>
)
}
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, useInView } from 'framer-motion'
import { personalInfo, stats, type Stat } 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 bioLines = personalInfo.bio.split('\n\n')
return (
<section id="about" ref={sectionRef} className="py-24" aria-label="About section">
<div className="section-container">
{/* Section Heading */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
>
<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)' }}
/>
</motion.div>
{/* Two-column layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
{/* Left — Text */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.2 }}
className="space-y-5"
>
{bioLines.map((line, i) => (
<p key={i} className="text-base leading-relaxed" style={{ color: 'var(--color-muted2)' }}>
{line}
</p>
))}
{/* 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)',
}}
>
{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"
>
{stats.map((stat) => (
<StatCard key={stat.label} stat={stat} trigger={statsInView} />
))}
</motion.div>
</div>
</div>
</section>
)
}
'use client'
import { useRef } from 'react'
import { motion, useInView, useReducedMotion } from 'framer-motion'
import { fadeUp, fadeUpReduced } from '@/lib/animations'
interface AnimatedSectionProps {
children: React.ReactNode
className?: string
delay?: number
}
export default function AnimatedSection({
children,
className,
delay = 0,
}: AnimatedSectionProps) {
const ref = useRef<HTMLDivElement>(null)
const isInView = useInView(ref, { once: true, margin: '-80px' })
const prefersReduced = useReducedMotion()
return (
<motion.div
ref={ref}
variants={prefersReduced ? fadeUpReduced : fadeUp}
custom={delay / 0.12}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className={className}
>
{children}
</motion.div>
)
}
'use client'
import { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { ArrowUp } from 'lucide-react'
export default function BackToTop() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const handleScroll = () => {
setVisible(window.scrollY > 500)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [])
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<AnimatePresence>
{visible && (
<motion.button
initial={{ opacity: 0, scale: 0.5 }}
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"
style={{
background: 'linear-gradient(135deg, #F97316 0%, #FB923C 100%)',
color: '#0A0F1E',
boxShadow: '0 0 20px rgba(249,115,22,0.4)',
}}
aria-label="Back to top"
>
<ArrowUp size={20} strokeWidth={2.5} />
</motion.button>
)}
</AnimatePresence>
)
}
'use client'
import { useRef, useState } from 'react'
import { motion, useInView } from 'framer-motion'
import { Mail, Send } from 'lucide-react'
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 {
id: string
label: string
type?: string
value: string
onChange: (v: string) => void
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',
}
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>
{multiline ? (
<textarea
id={id}
rows={4}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={baseStyle}
/>
) : (
<input
id={id}
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
style={baseStyle}
/>
)}
{/* 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>
)
}
// ─── 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 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"
>
<div className="section-container">
{/* Section Heading */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
>
<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)' }}
/>
</motion.div>
{/* Split Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start">
{/* Left — Message + Links */}
<motion.div
initial={{ opacity: 0, x: -30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.2 }}
>
<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>
<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>
</motion.div>
{/* Right — Form */}
<motion.div
initial={{ opacity: 0, x: 30 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.7, delay: 0.3 }}
>
<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>
</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>
)
}
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"
>
<div className="section-container">
{/* Section Heading */}
<motion.div
variants={headingVariants}
custom={0}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="mb-16"
>
<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)' }}
/>
</motion.div>
{/* Timeline */}
<div className="max-w-3xl">
{timelineEvents.map((event, i) => (
<TimelineNode
key={event.id}
event={event}
index={i}
isInView={isInView}
isLast={i === timelineEvents.length - 1}
prefersReduced={prefersReduced}
/>
))}
</div>
</div>
</section>
)
}
'use client'
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' })
}
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>
{/* 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>
{/* 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>
</div>
</footer>
)
}
'use client'
import { useEffect, useRef, useState } from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import { Mail, ChevronDown } from 'lucide-react'
import { useTypingEffect } from '@/hooks/useTypingEffect'
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>
{/* i=1 Name */}
<motion.h1
variants={variants}
custom={1}
initial="hidden"
animate="visible"
className="gradient-text hero-name mb-4 leading-none"
>
{personalInfo.name}
</motion.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>
{/* 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>
{/* 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)',
}}
>
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>
</motion.div>
{/* i=5 Social icons + scroll arrow */}
<motion.div
variants={variants}
custom={5}
initial="hidden"
animate="visible"
className="flex items-center gap-4"
>
<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)' }}
aria-label="GitHub profile"
>
<GithubIcon size={18} />
<span className="group-hover:text-[var(--color-text)] transition-colors">
github.com/{personalInfo.github}
</span>
</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"
>
<Mail size={18} className="group-hover:text-[var(--primary)] transition-colors" />
<span className="group-hover:text-[var(--color-text)] transition-colors">
{personalInfo.email}
</span>
</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>
</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 { 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>('')
useEffect(() => {
const observers: IntersectionObserver[] = []
sectionIds.forEach((id) => {
const el = document.getElementById(id)
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setActive(id)
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 }
)
observer.observe(el)
observers.push(observer)
})
return () => observers.forEach((o) => o.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 active = useActiveSection(sectionIds)
const handleScroll = useCallback(() => {
setScrolled(window.scrollY > 60)
}, [])
useEffect(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [handleScroll])
const handleNavClick = (href: string) => {
setMobileOpen(false)
const id = href.slice(1)
const el = document.getElementById(id)
el?.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)',
}}
>
{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>
</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>
</>
)
}
'use client'
import { motion } from 'framer-motion'
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) {
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
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',
}}
>
<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>
</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>
)}
</div>
</motion.div>
)
}
'use client'
import { useRef, Suspense } from 'react'
import dynamic from 'next/dynamic'
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>
)
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"
>
<div className="section-container">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
className="mb-16"
>
<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>
</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"
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)',
}}
>
<GithubIcon size={16} />
View All on GitHub
<ExternalLink size={14} />
</a>
</motion.div>
</div>
</section>
)
}
'use client'
export function SkeletonCard({ large = false, tall = false }: { large?: boolean; tall?: boolean }) {
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="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>
<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>
</div>
)
}
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>
)
}
'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'
// ─── 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 prefersReduced = useReducedMotion()
const currentCategory = skillCategories.find((c) => c.tab === activeTab)!
const tabs: SkillTab[] = ['Backend', 'Frontend', 'DevOps', 'Database']
const headingVariants = prefersReduced ? fadeUpReduced : fadeUp
return (
<section
id="skills"
ref={sectionRef}
className="py-24"
style={{ background: 'var(--color-surface)' }}
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"
>
<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)' }}
/>
</motion.div>
{/* Tab Switcher */}
<motion.div
variants={headingVariants}
custom={1}
initial="hidden"
animate={isInView ? 'visible' : 'hidden'}
className="flex flex-wrap gap-2 mb-10"
role="tablist"
aria-label="Skill categories"
>
{tabs.map((tab) => (
<button
key={tab}
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',
}}
>
{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>
</section>
)
}
'use client'
import { useEffect, useState, Suspense } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
function ProgressBarContent() {
const pathname = usePathname()
const searchParams = useSearchParams()
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
const timer = setTimeout(() => setLoading(false), 500)
return () => clearTimeout(timer)
}, [pathname, searchParams])
if (!loading) return null
return (
<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',
}}
/>
)
}
export default function TopProgressBar() {
return (
<Suspense fallback={null}>
<ProgressBarContent />
</Suspense>
)
}
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
import { useEffect, useRef, useState } from 'react'
const ROLES = [
'Java Backend Developer',
'Spring Boot Engineer',
'System Architecture Enthusiast',
]
type Phase = 'typing' | 'deleting' | 'pausing'
interface TypingState {
displayText: string
phase: Phase
}
const TYPE_SPEED = 75
const DELETE_SPEED = 38
const PAUSE_AFTER_TYPE = 1800
const PAUSE_AFTER_DELETE = 400
export function useTypingEffect(): TypingState {
const [displayText, setDisplayText] = useState('')
const [phase, setPhase] = useState<Phase>('typing')
const roleIndex = useRef(0)
const charIndex = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
function tick() {
const currentRole = ROLES[roleIndex.current]
if (phase === 'typing') {
if (charIndex.current < currentRole.length) {
charIndex.current++
setDisplayText(currentRole.slice(0, charIndex.current))
timerRef.current = setTimeout(tick, TYPE_SPEED)
} else {
setPhase('pausing')
timerRef.current = setTimeout(() => {
setPhase('deleting')
}, PAUSE_AFTER_TYPE)
}
} else if (phase === 'deleting') {
if (charIndex.current > 0) {
charIndex.current--
setDisplayText(currentRole.slice(0, charIndex.current))
timerRef.current = setTimeout(tick, DELETE_SPEED)
} else {
roleIndex.current = (roleIndex.current + 1) % ROLES.length
setPhase('pausing')
timerRef.current = setTimeout(() => {
setPhase('typing')
}, PAUSE_AFTER_DELETE)
}
}
}
timerRef.current = setTimeout(tick, phase === 'typing' && charIndex.current === 0 ? 600 : 0)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [phase])
return { displayText, phase }
}
import type { Variants } from 'framer-motion'
// ─── Shared Framer Motion Variants ───────────────────────────
export const fadeUp: Variants = {
hidden: { opacity: 0, y: 32 },
visible: (i: number = 0) => ({
opacity: 1,
y: 0,
transition: { delay: i * 0.12, duration: 0.55, ease: 'easeOut' },
}),
}
export const fadeIn: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.5 } },
}
// Reduced-motion safe versions (opacity only, no transforms)
export const fadeUpReduced: Variants = {
hidden: { opacity: 0 },
visible: (i: number = 0) => ({
opacity: 1,
transition: { delay: i * 0.08, duration: 0.3 },
}),
}
export const fadeInReduced: Variants = {
hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.3 } },
}
// ============================================================
// lib/data.ts — All portfolio content as typed TypeScript constants
// ============================================================
// ─── Personal Info ───────────────────────────────────────────
export const personalInfo = {
name: 'Luu Duc Dat',
initials: 'LDD',
role: 'Java Backend Developer',
tagline: 'I build high-performance backend systems with Java & Spring Boot.',
location: 'Ho Chi Minh City, Vietnam',
email: 'tkqb0ducdat@gmail.com',
github: 'datdat054',
githubUrl: 'https://github.com/datdat054',
university: 'Posts and Telecommunications Institute of Technology – HCMC Campus',
major: 'Software Engineering (Information Technology)',
gpa: '3.0 / 4.0',
careerGoal: 'Become a Senior Backend Engineer within 3 years, specializing in distributed systems.',
bio: "I'm a backend-focused developer studying Software Engineering at PTIT HCMC. My work centers on Java and the Spring ecosystem — building RESTful APIs, microservices architectures, and systems that handle real load.\n\nI care about writing clean, maintainable code and understanding the 'why' behind design decisions. My goal is to join a team where I can grow into a senior backend role within the next three years.",
shortBio: 'Software Engineering student at PTIT HCMC · Focused on microservices & distributed systems',
} as const
// ─── Typing Roles (Hero animated typewriter) ──────────────────
export const typingRoles: string[] = [
'Java Backend Developer',
'Spring Boot Engineer',
'System Architecture Enthusiast',
]
// ─── Stats ────────────────────────────────────────────────────
export interface Stat {
value: number
suffix: string
label: string
}
export const stats: Stat[] = [
{ value: 2, suffix: '+', label: 'Years of coding' },
{ value: 4, suffix: '+', label: 'Projects shipped' },
{ value: 2, suffix: '', label: 'Hackathons competed' },
]
// ─── Skills ───────────────────────────────────────────────────
export type SkillTab = 'Backend' | 'Frontend' | 'DevOps' | 'Database'
export interface Skill {
name: string
level: number
color?: string
}
export interface SkillCategory {
tab: SkillTab
skills: Skill[]
}
export const skillCategories: SkillCategory[] = [
{
tab: 'Backend',
skills: [
{ name: 'Java (Core / OOP)', level: 95 },
{ name: 'Spring Boot', level: 90 },
{ name: 'RESTful APIs', level: 90 },
{ name: 'Spring Data JPA / Hibernate', level: 85 },
{ name: 'Spring Security', level: 80 },
{ name: 'JWT Auth', level: 80 },
{ name: 'Microservices', level: 75 },
{ name: 'Spring Cloud', level: 70 },
],
},
{
tab: 'Frontend',
skills: [
{ name: 'HTML / CSS', level: 80 },
{ name: 'Tailwind CSS', level: 75 },
{ name: 'Next.js', level: 70 },
{ name: 'React', level: 72 },
{ name: 'TypeScript', level: 68 },
],
},
{
tab: 'DevOps',
skills: [
{ name: 'Postman', level: 88 },
{ name: 'Git / GitHub', level: 85 },
{ name: 'Swagger', level: 80 },
{ name: 'Linux CLI', level: 65 },
{ name: 'Docker', level: 60 },
],
},
{
tab: 'Database',
skills: [
{ name: 'MySQL', level: 85 },
{ name: 'PostgreSQL', level: 83 },
{ name: 'Microsoft SQL Server', level: 72 },
{ name: 'MongoDB', level: 70 },
],
},
]
// ─── Projects ─────────────────────────────────────────────────
export interface Project {
id: string
name: string
period: string
role?: string
description: string
stack: string[]
githubUrl?: string
featured?: boolean
variant: 'featured' | 'card2' | 'card3' | 'card4'
}
export const projects: Project[] = [
{
id: 'ecommerce-microservices',
name: 'E-Commerce Microservices System',
period: 'Jan 2026 – Apr 2026',
role: 'Java Backend Developer (Personal/Academic project)',
description:
'Backend split into three independent services — Product, Order, and User. Uses Spring Cloud Eureka for service discovery and Gateway for routing. Each service manages its own database (MySQL/PostgreSQL). JWT handles authentication across all endpoints.',
stack: ['Java', 'Spring Boot', 'Spring Cloud', 'Eureka', 'API Gateway', 'JPA', 'JWT', 'MySQL', 'PostgreSQL', 'Docker'],
githubUrl: 'https://github.com/datdat054',
featured: true,
variant: 'featured',
},
{
id: 'course-management-api',
name: 'Online Course Management RESTful API',
period: 'Sep 2025 – Dec 2025',
role: 'Java Backend Developer (Course project)',
description:
'RESTful API for course registration and lecture management. Built with Spring Boot and Hibernate for clean ORM and transaction handling. Includes service-layer unit tests and full Swagger documentation.',
stack: ['Java', 'Spring Boot', 'Hibernate', 'REST API', 'Unit Testing', 'Postman', 'Swagger', 'MySQL'],
githubUrl: 'https://github.com/datdat054',
variant: 'card2',
},
{
id: 'plant-management',
name: 'Plant Management System',
period: '2025',
description:
'REST API backend for tracking plant and crop data, supporting growth schedules and status monitoring.',
stack: ['Spring Boot', 'PostgreSQL', 'REST API'],
githubUrl: 'https://github.com/datdat054',
variant: 'card3',
},
{
id: 'drowsiness-detection',
name: 'Driver Drowsiness Detection',
period: '2025',
description:
'Embedded real-time detection system on ESP32-CAM using FreeRTOS for task scheduling and C++ for processing logic.',
stack: ['ESP32', 'FreeRTOS', 'C++', 'Embedded'],
githubUrl: 'https://github.com/datdat054',
variant: 'card4',
},
]
// ─── Experience / Timeline ────────────────────────────────────
export interface TimelineEvent {
id: string
date: string
title: string
subtitle: string
description: string
badge?: string
badgeColor?: 'orange' | 'blue' | 'green' | 'purple'
type: 'project' | 'hackathon' | 'education'
}
export const timelineEvents: TimelineEvent[] = [
{
id: 'ecommerce-proj',
date: 'Jan – Apr 2026',
title: 'E-Commerce Microservices',
subtitle: '',
description: 'Personal backend project. Implemented Eureka, Gateway, and JWT from scratch.',
badge: 'Project',
badgeColor: 'orange',
type: 'project',
},
{
id: 'course-api-proj',
date: 'Sep – Dec 2025',
title: 'Course Management API',
subtitle: '',
description: 'Course project. Focused on clean REST design, Hibernate ORM, and Swagger docs.',
badge: 'Project',
badgeColor: 'orange',
type: 'project',
},
{
id: 'techsolve-hackathon',
date: '2026',
title: 'TechSolve Hackathon',
subtitle: '',
description: 'Competed in a 48h hackathon building a full-stack web application with a team.',
badge: 'Hackathon',
badgeColor: 'purple',
type: 'hackathon',
},
{
id: 'mockai-hackathon',
date: '2025',
title: 'MockAI Hackathon',
subtitle: '',
description: 'Built an AI-driven mock interview platform — React frontend, Node.js backend.',
badge: 'Hackathon',
badgeColor: 'purple',
type: 'hackathon',
},
{
id: 'ptit-education',
date: 'Dec 2022 – Present',
title: 'PTIT HCMC',
subtitle: '',
description: 'Software Engineering, GPA 3.0/4.0. Key subjects: OOP, DSA, System Design, Software Architecture.',
badge: 'Education',
badgeColor: 'blue',
type: 'education',
},
]
// ─── Navigation Links ─────────────────────────────────────────
export interface NavLink {
label: string
href: string
}
export const navLinks: NavLink[] = [
{ label: 'About', href: '#about' },
{ label: 'Skills', href: '#skills' },
{ label: 'Projects', href: '#projects' },
{ label: 'Experience', href: '#experience' },
{ label: 'Contact', href: '#contact' },
]
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"framer-motion": "^12.42.0",
"lucide-react": "^1.22.0",
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"tailwindcss": "^4",
"typescript": "^5"
}
}
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F97316" />
<stop offset="100%" stop-color="#FB923C" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#grad)" />
<text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="24" font-weight="800" fill="#0A0F1E" text-anchor="middle" dominant-baseline="middle" letter-spacing="1">LDD</text>
</svg>
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
\ No newline at end of file
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
\ No newline at end of file
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
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