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.
This diff is collapsed.
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 />
</>
)
}
This diff is collapsed.
'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>
)
}
This diff is collapsed.
'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>
)
}
This diff is collapsed.
'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 diff is collapsed.
{
"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