diff --git a/README.md b/README.md index e18becf..647a826 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,17 @@ npm run deploy # gh-pages -d build (uses homepage in package.json) GitHub Pages is configured via `homepage` in `package.json`. The `deploy` script publishes the `build/` folder to the `gh-pages` branch. +### Prebuild hook + +`npm run build` runs `scripts/fetch-github-activity.js` first. The script +calls the public GitHub events API for the user, maps the response into +the shape the UI expects, and writes +`src/components/github/recent-activity.json`. If the request fails (rate +limit, network), the existing JSON is kept so the build never breaks. + +Set `GITHUB_TOKEN` (or `GH_TOKEN`) in your environment to authenticate +the request and avoid the unauthenticated 60-req/hr limit. + ## Image pipeline `scripts/optimize-images.js` regenerates JPG + WebP variants from the source @@ -68,6 +79,9 @@ node scripts/optimize-images.js `scripts/generate-favicon.js` regenerates the favicon set in `public/` from `src/assets/avatar.jpg`. +`scripts/fetch-github-activity.js` runs as a prebuild hook (see above). You +can run it on demand to refresh local data without doing a full build. + ## Project structure ``` diff --git a/package.json b/package.json index 3ed3053..b632a39 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "react-scripts start", "predeploy": "npm run build", "deploy": "gh-pages -d build", + "prebuild": "node scripts/fetch-github-activity.js", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" diff --git a/public/index.html b/public/index.html index 0aee0f4..10c4ec1 100644 --- a/public/index.html +++ b/public/index.html @@ -8,6 +8,35 @@ name="description" content="Vũ Xuân Anh — Full Stack Developer. React, TypeScript, Node. Portfolio and contact." /> + + + + + + + + + + + + + + + + + + + + + + + @@ -15,6 +44,62 @@ Vũ Xuân Anh — Full Stack Developer + + + + diff --git a/public/og-image.png b/public/og-image.png new file mode 100644 index 0000000..b8f52b5 Binary files /dev/null and b/public/og-image.png differ diff --git a/scripts/fetch-github-activity.js b/scripts/fetch-github-activity.js new file mode 100644 index 0000000..0084127 --- /dev/null +++ b/scripts/fetch-github-activity.js @@ -0,0 +1,151 @@ +/* eslint-disable */ +const fs = require("fs"); +const path = require("path"); +const https = require("https"); + +const USERNAME = process.env.GITHUB_USERNAME || "anhvuFE"; +const OUT = path.join(__dirname, "..", "src", "components", "github", "recent-activity.json"); +const MAX_EVENTS = 5; + +function shorten(text, max = 60) { + if (!text) return ""; + const oneLine = text.split("\n")[0]; + if (oneLine.length <= max) return oneLine; + return oneLine.slice(0, max - 1).trimEnd() + "…"; +} + +function mapEvent(e) { + const repo = e.repo?.name; + if (!repo) return null; + const repoUrl = `https://github.com/${repo}`; + const date = e.created_at; + + switch (e.type) { + case "PushEvent": { + const commits = e.payload?.commits || []; + const first = commits[0]; + if (!first) return null; + const msg = shorten(first.message); + const more = commits.length > 1 ? ` (+${commits.length - 1} more)` : ""; + return { + type: "commit", + repo, + message: `${msg}${more} in ${repo}`, + url: `${repoUrl}/commit/${first.sha}`, + date + }; + } + case "PullRequestEvent": { + if (!e.payload?.pull_request) return null; + const title = shorten(e.payload.pull_request.title); + const num = e.payload.pull_request.number; + const desc = title ? `: ${title}` : num ? ` #${num}` : ""; + return { + type: "pr", + repo, + message: `${e.payload.action} PR${desc} in ${repo}`, + url: e.payload.pull_request.html_url || `${repoUrl}/pulls`, + date + }; + } + case "IssuesEvent": { + if (!e.payload?.issue) return null; + const title = shorten(e.payload.issue.title); + const num = e.payload.issue.number; + const desc = title ? `: ${title}` : num ? ` #${num}` : ""; + return { + type: "issue", + repo, + message: `${e.payload.action} issue${desc} in ${repo}`, + url: e.payload.issue.html_url || `${repoUrl}/issues`, + date + }; + } + case "WatchEvent": + return { type: "star", repo, message: `starred ${repo}`, url: repoUrl, date }; + case "ForkEvent": + return { type: "fork", repo, message: `forked ${repo}`, url: repoUrl, date }; + case "CreateEvent": + if (e.payload?.ref_type === "repository" || e.payload?.ref_type === "branch") { + const ref = e.payload.ref ? ` ${e.payload.ref}` : ""; + return { + type: "create", + repo, + message: `created ${e.payload.ref_type}${ref}`, + url: repoUrl, + date + }; + } + return null; + default: + return null; + } +} + +function fetchEvents() { + return new Promise((resolve, reject) => { + const options = { + hostname: "api.github.com", + path: `/users/${USERNAME}/events/public?per_page=30`, + headers: { + "User-Agent": "portfolio-prebuild", + Accept: "application/vnd.github+json" + } + }; + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) options.headers.Authorization = `Bearer ${token}`; + + const req = https.get(options, (res) => { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + try { + resolve(JSON.parse(body)); + } catch (err) { + reject(new Error(`Invalid JSON from GitHub: ${err.message}`)); + } + } else { + reject(new Error(`GitHub API ${res.statusCode}: ${body.slice(0, 200)}`)); + } + }); + }); + req.on("error", reject); + req.setTimeout(10000, () => { + req.destroy(new Error("GitHub API request timed out")); + }); + }); +} + +function readExisting() { + try { + return JSON.parse(fs.readFileSync(OUT, "utf8")); + } catch { + return null; + } +} + +(async () => { + let payload; + try { + const events = await fetchEvents(); + const mapped = events.map(mapEvent).filter((e) => e !== null).slice(0, MAX_EVENTS); + payload = { fetchedAt: new Date().toISOString(), events: mapped }; + console.log(`[github] fetched ${mapped.length} events for ${USERNAME}`); + } catch (err) { + console.warn(`[github] fetch failed (${err.message}); keeping existing data`); + const existing = readExisting(); + if (existing) { + console.log(`[github] using cached data from ${existing.fetchedAt}`); + return; + } + payload = { fetchedAt: new Date().toISOString(), events: [] }; + } + + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(payload, null, 2) + "\n"); + console.log(`[github] wrote ${OUT}`); +})().catch((err) => { + console.error(err); + process.exit(0); // never block build +}); diff --git a/scripts/generate-og-image.js b/scripts/generate-og-image.js new file mode 100644 index 0000000..90eb025 --- /dev/null +++ b/scripts/generate-og-image.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +const path = require("path"); +const fs = require("fs"); +const sharp = require("sharp"); + +const SOURCE = path.join(__dirname, "..", "src", "assets", "avatar.jpg"); +const OUT = path.join(__dirname, "..", "public", "og-image.png"); + +const W = 1200; +const H = 630; +const AVATAR_SIZE = 360; +const PAD = 80; + +const BG = "#0a0a0a"; +const ACCENT = "#0eaddf"; +const TEXT_PRIMARY = "#e6edf3"; +const TEXT_MUTED = "#8b949e"; + +async function main() { + if (!fs.existsSync(SOURCE)) { + console.error("Missing source:", SOURCE); + process.exit(1); + } + + // Avatar with rounded corners + const avatarBuffer = await sharp(SOURCE) + .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover" }) + .composite([ + { + input: Buffer.from( + `` + ), + blend: "dest-in" + } + ]) + .png() + .toBuffer(); + + const avatarX = PAD; + const avatarY = Math.round((H - AVATAR_SIZE) / 2); + const textX = avatarX + AVATAR_SIZE + 56; + + const svg = ` + + + + + + + + + + + + FULL STACK DEVELOPER + + + + Vũ Xuân Anh + + + + React · TypeScript · Node + + + + + + Hanoi, Vietnam · open to freelance & full-time + + + + anhvuFE.github.io/portfolio + + + + github.com/anhvuFE + + + `; + + await sharp(Buffer.from(svg)) + .composite([{ input: avatarBuffer, top: avatarY, left: avatarX }]) + .png({ quality: 92 }) + .toFile(OUT); + + const bytes = fs.statSync(OUT).size; + console.log(`og-image.png ${W}x${H} ${(bytes / 1024).toFixed(1)} KB`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/App.tsx b/src/App.tsx index a4b167a..0da57af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { lazy, Suspense, useCallback, useEffect, useState } from "react"; import { ThemeProvider, createTheme, CssBaseline } from "@mui/material"; import "./App.css"; import Header from "./components/header/Header"; @@ -8,6 +8,8 @@ import Footer from "./components/footer/Footer"; import CursorGlow from "./components/effects/CursorGlow"; import ScrollReveal from "./components/effects/ScrollReveal"; +const CommandPalette = lazy(() => import("./components/palette/CommandPalette")); + const theme = createTheme({ palette: { mode: "dark", @@ -98,11 +100,26 @@ const theme = createTheme({ }); function App(): React.ReactElement { + const [paletteOpen, setPaletteOpen] = useState(false); + + const closePalette = useCallback(() => setPaletteOpen(false), []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen((prev) => !prev); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + return ( -
+
setPaletteOpen(true)} />
@@ -110,6 +127,11 @@ function App(): React.ReactElement {
+ {paletteOpen && ( + + + + )} ); } diff --git a/src/components/bento/BentoGrid.tsx b/src/components/bento/BentoGrid.tsx index 8ee4f8b..fc8a46c 100644 --- a/src/components/bento/BentoGrid.tsx +++ b/src/components/bento/BentoGrid.tsx @@ -8,7 +8,8 @@ import { ExperiencePreview, CertsPreview, ServicesPreview, - ContactPreview + ContactPreview, + ProjectsPreview } from "./previews"; import GitHubPreview from "./GitHubPreview"; @@ -19,6 +20,7 @@ const Services = lazy(() => import("../services/Services")); const Qualification = lazy(() => import("../qualification/Qualification")); const Certificate = lazy(() => import("../certificate/Certificate")); const Contact = lazy(() => import("../contact/Contact")); +const Projects = lazy(() => import("../projects/Projects")); type SectionKey = | "about" @@ -27,7 +29,8 @@ type SectionKey = | "experience" | "certificates" | "services" - | "contact"; + | "contact" + | "projects"; const sectionContent: Record> = { about: About, @@ -36,7 +39,8 @@ const sectionContent: Record> = experience: Qualification, certificates: Certificate, services: Services, - contact: Contact + contact: Contact, + projects: Projects }; const DrawerLoader: React.FC = () => ( @@ -52,7 +56,8 @@ const validKeys = new Set([ "experience", "certificates", "services", - "contact" + "contact", + "projects" ]); const BentoGrid: React.FC = () => { @@ -97,7 +102,8 @@ const BentoGrid: React.FC = () => { experience: () => open("experience"), certificates: () => open("certificates"), services: () => open("services"), - contact: () => open("contact") + contact: () => open("contact"), + projects: () => open("projects") }), [open] ); @@ -185,6 +191,14 @@ const BentoGrid: React.FC = () => { onClick={handlers.services} gridColumn={{ md: "5 / span 2" }} /> + } + onClick={handlers.projects} + gridColumn={{ xs: "1 / -1", md: "1 / -1" }} + /> ( ); + +export const ProjectsPreview: React.FC = () => ( + + + + + 3 case studies + + + + Shopify app · Marketing rebuild · Ops dashboard + + + Problem → Approach → Result for each + + +); diff --git a/src/components/footer/Footer.tsx b/src/components/footer/Footer.tsx index bea709a..98deaf2 100644 --- a/src/components/footer/Footer.tsx +++ b/src/components/footer/Footer.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Container, Typography, IconButton } from "@mui/material"; import { ArrowUpward as ArrowUpIcon } from "@mui/icons-material"; import MatrixRain from "../sakura/MatrixRain"; @@ -17,58 +17,240 @@ const float = keyframes` const MONO = '"Fira Code", "JetBrains Mono", Menlo, Monaco, Consolas, "Courier New", monospace'; interface Line { - prompt?: string; + prompt?: boolean; command?: string; output?: React.ReactNode; } +const linkSx = { + color: "#0eaddf", + textDecoration: "underline", + textDecorationColor: "rgba(14, 173, 223, 0.3)", + textUnderlineOffset: 3, + transition: "all 0.2s ease", + "&:hover": { + color: "#3dc4ee", + textDecorationColor: "#3dc4ee" + } +}; + +const dotSx = (color: string) => ({ + width: 12, + height: 12, + borderRadius: "50%", + background: color, + flexShrink: 0 +}); + +const initialLines: Line[] = [ + { prompt: true, command: "whoami" }, + { output: Vũ Xuân Anh — Full Stack Developer }, + { prompt: true, command: "cat /status" }, + { + output: ( + + + available for new opportunities + + ) + }, + { prompt: true, command: "help" }, + { + output: ( + + try: about{" · "} + skills{" · "} + projects{" · "} + contact{" · "} + clear + + ) + } +]; + +interface CommandResult { + output?: React.ReactNode; + clear?: boolean; +} + +function runCommand(input: string): CommandResult { + const trimmed = input.trim(); + if (!trimmed) return { output: null }; + const [cmd, ...args] = trimmed.split(/\s+/); + const arg = args.join(" "); + + switch (cmd.toLowerCase()) { + case "help": + return { + output: ( + + available: about,{" "} + skills,{" "} + projects,{" "} + contact,{" "} + whoami,{" "} + echo <text>,{" "} + clear + + ) + }; + case "whoami": + return { output: Vũ Xuân Anh — Full Stack Developer }; + case "about": + case "cat": + if (cmd.toLowerCase() === "cat" && arg.toLowerCase() !== "about" && arg.toLowerCase() !== "skills") { + return { output: cat: {arg || "missing operand"}: No such file }; + } + if (cmd.toLowerCase() === "cat" && arg.toLowerCase() === "skills") { + return { + output: ( + + react, typescript, node, next.js, mui, postgres, mongo, docker, aws + + ) + }; + } + return { + output: ( + + full-stack dev, ~3 yrs across 4 companies. frontend-leaning. currently @ neliSoftwares. + + ) + }; + case "skills": + return { + output: ( + + react, typescript, node, next.js, mui, postgres, mongo, docker, aws + + ) + }; + case "projects": + case "ls": + return { + output: ( + + scroll up and tap the{" "} + Projects bento card for case studies + + ) + }; + case "contact": + return { + output: ( + + github.com/anhvuFE + {" "} + linkedin + {" "} + vuxuananh22@gmail.com + + ) + }; + case "echo": + return { output: {arg} }; + case "clear": + return { clear: true }; + case "sudo": + return { output: nice try. }; + case "rm": + return { output: permission denied. }; + default: + return { + output: ( + + command not found: {cmd} — try{" "} + help + + ) + }; + } +} + const Footer: React.FC = () => { - const scrollToTop = (): void => { + const [history, setHistory] = useState(initialLines); + const [input, setInput] = useState(""); + const [historyIndex, setHistoryIndex] = useState(null); + const inputRef = useRef(null); + const bodyRef = useRef(null); + + const commandHistory = useMemo( + () => history.filter((l) => l.prompt && l.command).map((l) => l.command as string), + [history] + ); + + const scrollToTop = useCallback((): void => { window.scrollTo({ top: 0, behavior: "smooth" }); - }; + }, []); const currentYear: number = new Date().getFullYear(); - const lines: Line[] = [ - { command: "whoami" }, - { output: Vũ Xuân Anh — Full Stack Developer }, - {}, - { command: "cat /status" }, - { - output: ( - - - available for new opportunities - - ) - }, - {}, - { command: "ls ~/contact" }, - { - output: ( - - github.com/anhvuFE - {" "} - linkedin.com/xu... - {" "} - vuxuananh22@gmail.com - - ) + const focusInput = useCallback(() => { + inputRef.current?.focus({ preventScroll: true }); + }, []); + + useEffect(() => { + if (bodyRef.current) { + bodyRef.current.scrollTop = bodyRef.current.scrollHeight; + } + }, [history]); + + const submit = useCallback(() => { + const value = input; + const result = runCommand(value); + if (result.clear) { + setHistory([]); + } else { + setHistory((prev) => [ + ...prev, + { prompt: true, command: value }, + ...(result.output !== null && result.output !== undefined ? [{ output: result.output }] : []) + ]); + } + setInput(""); + setHistoryIndex(null); + }, [input]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (commandHistory.length === 0) return; + const next = historyIndex === null ? commandHistory.length - 1 : Math.max(0, historyIndex - 1); + setHistoryIndex(next); + setInput(commandHistory[next] ?? ""); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + if (historyIndex === null) return; + const next = historyIndex + 1; + if (next >= commandHistory.length) { + setHistoryIndex(null); + setInput(""); + } else { + setHistoryIndex(next); + setInput(commandHistory[next] ?? ""); + } + } }, - {}, - { command: "echo $LOCATION" }, - { output: Hanoi, Vietnam · GMT+7 } - ]; + [submit, commandHistory, historyIndex] + ); return ( { - {/* Terminal window */} - {/* Title bar */} { userSelect: "none" }} > - vuxuananh@portfolio: ~ + vuxuananh@portfolio: ~ — interactive - {/* Body */} - {lines.map((line, i) => ( + {history.map((line, i) => ( - {line.command && ( + {line.prompt && ( <> vu@portfolio : @@ -171,33 +355,60 @@ const Footer: React.FC = () => { {line.command} )} - {line.output && {line.output}} - {!line.command && !line.output && <> } + {line.output && {line.output}} + {!line.prompt && !line.output && <> } ))} - {/* Active prompt with blinking cursor */} vu@portfolio : ~ ) => setInput(e.target.value)} + onKeyDown={onKeyDown} + spellCheck={false} + autoComplete="off" + autoCapitalize="off" + autoCorrect="off" + aria-label="terminal input" sx={{ - display: "inline-block", - width: "0.55em", - height: "1em", - background: "#0eaddf", - animation: `${blink} 1s step-end infinite`, - verticalAlign: "text-bottom" + flex: 1, + background: "transparent", + border: "none", + outline: "none", + color: "#e6edf3", + fontFamily: MONO, + fontSize: "inherit", + caretColor: "#0eaddf", + padding: 0, + minWidth: 0 }} /> + {input.length === 0 && ( + + )} - {/* Below-terminal credit */} { }} > {`// © ${currentYear} Vũ Xuân Anh`} - {"// built with React, TypeScript & MUI"} + {"// type 'help' in the terminal above"} ); }; -const dotSx = (color: string) => ({ - width: 12, - height: 12, - borderRadius: "50%", - background: color, - flexShrink: 0 -}); - -const linkSx = { - color: "#0eaddf", - textDecoration: "underline", - textDecorationColor: "rgba(14, 173, 223, 0.3)", - textUnderlineOffset: 3, - transition: "all 0.2s ease", - "&:hover": { - color: "#3dc4ee", - textDecorationColor: "#3dc4ee" - } -}; - export default Footer; diff --git a/src/components/github/GitHubActivity.tsx b/src/components/github/GitHubActivity.tsx index fb5f30c..fb3596d 100644 --- a/src/components/github/GitHubActivity.tsx +++ b/src/components/github/GitHubActivity.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Box, Container, Typography, Paper, useTheme, useMediaQuery } from "@mui/material"; import { GitHubCalendar } from "react-github-calendar"; +import RecentActivity from "./RecentActivity"; const GitHubActivity: React.FC = () => { const theme = useTheme(); @@ -88,6 +89,10 @@ const GitHubActivity: React.FC = () => { }} /> + + + + ); diff --git a/src/components/github/RecentActivity.tsx b/src/components/github/RecentActivity.tsx new file mode 100644 index 0000000..a190809 --- /dev/null +++ b/src/components/github/RecentActivity.tsx @@ -0,0 +1,173 @@ +import React from "react"; +import { Box, Typography, Chip } from "@mui/material"; +import { + Commit as CommitIcon, + CallSplit as ForkIcon, + Star as StarIcon, + MergeType as PRIcon, + BugReport as IssueIcon, + Folder as RepoIcon, + GitHub as GitHubIcon, + OpenInNew as OpenInNewIcon +} from "@mui/icons-material"; +import data from "./recent-activity.json"; + +const USERNAME = "anhvuFE"; + +interface ActivityEvent { + type: "commit" | "pr" | "issue" | "star" | "fork" | "create"; + repo: string; + message: string; + url?: string; + date: string; +} + +interface ActivityData { + fetchedAt: string; + events: ActivityEvent[]; +} + +const activity = data as ActivityData; + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.round(diff / 60000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m ago`; + const h = Math.round(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.round(h / 24); + if (d < 30) return `${d}d ago`; + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +const iconMap: Record = { + commit: , + pr: , + issue: , + star: , + fork: , + create: +}; + +const colorMap: Record = { + commit: "#0eaddf", + pr: "#a855f7", + issue: "#f59e0b", + star: "#FFD700", + fork: "#22c55e", + create: "#3dc4ee" +}; + +const RecentActivity: React.FC = () => { + const events = activity.events; + + return ( + + + + + Recent activity + + + + updated {relativeTime(activity.fetchedAt)} + + + + {events.length === 0 ? ( + + View activity on github.com/{USERNAME} + + + ) : ( + + {events.map((e, i) => ( + + + {iconMap[e.type]} + + + {e.message} + + + + ))} + + )} + + ); +}; + +export default RecentActivity; diff --git a/src/components/github/recent-activity.json b/src/components/github/recent-activity.json new file mode 100644 index 0000000..a4d26cc --- /dev/null +++ b/src/components/github/recent-activity.json @@ -0,0 +1,40 @@ +{ + "fetchedAt": "2026-05-09T08:15:07.259Z", + "events": [ + { + "type": "pr", + "repo": "anhvuFE/portfolio", + "message": "merged PR #6 in anhvuFE/portfolio", + "url": "https://github.com/anhvuFE/portfolio/pulls", + "date": "2026-05-09T02:42:10Z" + }, + { + "type": "pr", + "repo": "anhvuFE/portfolio", + "message": "opened PR #6 in anhvuFE/portfolio", + "url": "https://github.com/anhvuFE/portfolio/pulls", + "date": "2026-05-09T02:40:55Z" + }, + { + "type": "create", + "repo": "anhvuFE/portfolio", + "message": "created branch chore/copy-favicon-and-polish", + "url": "https://github.com/anhvuFE/portfolio", + "date": "2026-05-09T02:40:27Z" + }, + { + "type": "pr", + "repo": "anhvuFE/portfolio", + "message": "merged PR #5 in anhvuFE/portfolio", + "url": "https://github.com/anhvuFE/portfolio/pulls", + "date": "2026-05-09T02:20:47Z" + }, + { + "type": "pr", + "repo": "anhvuFE/portfolio", + "message": "opened PR #5 in anhvuFE/portfolio", + "url": "https://github.com/anhvuFE/portfolio/pulls", + "date": "2026-05-09T02:20:18Z" + } + ] +} diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx index ea8fe10..57e118a 100644 --- a/src/components/header/Header.tsx +++ b/src/components/header/Header.tsx @@ -19,9 +19,14 @@ import { GitHub as GitHubIcon, LinkedIn as LinkedInIcon, Facebook as FacebookIcon, - MailOutline as MailIcon + MailOutline as MailIcon, + Search as SearchIcon } from "@mui/icons-material"; +interface HeaderProps { + onOpenPalette?: () => void; +} + interface NavItem { id: string; label: string; @@ -36,11 +41,18 @@ const navItems: NavItem[] = [ { id: "contact", label: "Contact" }, ]; -const Header: React.FC = () => { +const Header: React.FC = ({ onOpenPalette }) => { const [isMenuOpen, setIsMenuOpen] = useState(false); const [activeSection, setActiveSection] = useState("home"); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [isApple, setIsApple] = useState(false); + + useEffect(() => { + if (typeof navigator !== "undefined") { + setIsApple(/Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent)); + } + }, []); const trigger = useScrollTrigger({ disableHysteresis: true, @@ -167,19 +179,68 @@ const Header: React.FC = () => { {item.label} ))} + {onOpenPalette && ( + + + + {isApple ? "⌘ K" : "Ctrl K"} + + + )} ) : ( - setIsMenuOpen(true)} - sx={{ - color: "#e6edf3", - width: 40, - height: 40 - }} - > - - + + {onOpenPalette && ( + + + + )} + setIsMenuOpen(true)} + aria-label="Open menu" + sx={{ + color: "#e6edf3", + width: 40, + height: 40 + }} + > + + + )} diff --git a/src/components/palette/CommandPalette.tsx b/src/components/palette/CommandPalette.tsx new file mode 100644 index 0000000..2d250ac --- /dev/null +++ b/src/components/palette/CommandPalette.tsx @@ -0,0 +1,337 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Dialog, + DialogContent, + TextField, + Box, + Typography, + InputAdornment, + Chip +} from "@mui/material"; +import { + Search as SearchIcon, + Person as PersonIcon, + Code as CodeIcon, + GitHub as GitHubIcon, + Work as WorkIcon, + EmojiEvents as TrophyIcon, + Build as BuildIcon, + FolderSpecial as ProjectsIcon, + MailOutline as MailIcon, + Description as ResumeIcon, + LinkedIn as LinkedInIcon, + Home as HomeIcon, + KeyboardReturn as EnterIcon, + KeyboardArrowUp as UpIcon, + KeyboardArrowDown as DownIcon +} from "@mui/icons-material"; + +interface PaletteItem { + id: string; + label: string; + group: "Sections" | "Contact" | "Links"; + hint?: string; + icon: React.ReactElement; + hash?: string; + url?: string; + scrollTop?: boolean; +} + +const ITEMS: PaletteItem[] = [ + { id: "home", label: "Home", group: "Sections", icon: , scrollTop: true }, + { id: "about", label: "About", group: "Sections", icon: , hash: "about" }, + { id: "skills", label: "Skills", group: "Sections", icon: , hash: "skills" }, + { id: "github", label: "GitHub Activity", group: "Sections", icon: , hash: "github" }, + { id: "experience", label: "Experience", group: "Sections", icon: , hash: "experience" }, + { id: "certificates", label: "Certificates", group: "Sections", icon: , hash: "certificates" }, + { id: "services", label: "Services", group: "Sections", icon: , hash: "services" }, + { id: "projects", label: "Projects", group: "Sections", icon: , hash: "projects" }, + { + id: "contact", + label: "Contact form", + group: "Sections", + icon: , + hash: "contact" + }, + { + id: "email", + label: "Email vuxuananh22@gmail.com", + group: "Contact", + hint: "mailto", + icon: , + url: "mailto:vuxuananh22@gmail.com" + }, + { + id: "github-link", + label: "GitHub @anhvuFE", + group: "Links", + hint: "external", + icon: , + url: "https://github.com/anhvuFE" + }, + { + id: "linkedin-link", + label: "LinkedIn", + group: "Links", + hint: "external", + icon: , + url: "https://www.linkedin.com/in/xu%C3%A2n-anh-v%C5%A9-515580367/" + }, + { + id: "cv", + label: "Download CV", + group: "Links", + hint: "PDF", + icon: , + url: process.env.PUBLIC_URL + "/portfolio/static/media/CV-VuXuanAnh.pdf" + } +]; + +function score(query: string, label: string): number { + if (!query) return 1; + const q = query.toLowerCase(); + const l = label.toLowerCase(); + if (l === q) return 100; + if (l.startsWith(q)) return 80; + if (l.includes(q)) return 60; + // fuzzy: each query char appears in order + let li = 0; + for (let qi = 0; qi < q.length; qi++) { + while (li < l.length && l[li] !== q[qi]) li++; + if (li === l.length) return 0; + li++; + } + return 30; +} + +interface CommandPaletteProps { + open: boolean; + onClose: () => void; +} + +const CommandPalette: React.FC = ({ open, onClose }) => { + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const filtered = useMemo(() => { + return ITEMS.map((item) => ({ item, s: score(query, item.label) })) + .filter((x) => x.s > 0) + .sort((a, b) => b.s - a.s) + .map((x) => x.item); + }, [query]); + + const grouped = useMemo(() => { + const groups = new Map(); + for (const item of filtered) { + const existing = groups.get(item.group); + if (existing) existing.push(item); + else groups.set(item.group, [item]); + } + return Array.from(groups.entries()); + }, [filtered]); + + useEffect(() => { + if (open) { + setQuery(""); + setActiveIndex(0); + requestAnimationFrame(() => inputRef.current?.focus()); + } + }, [open]); + + useEffect(() => { + setActiveIndex(0); + }, [query]); + + const execute = useCallback( + (item: PaletteItem) => { + onClose(); + if (item.scrollTop) { + if (window.location.hash) { + window.history.pushState(null, "", window.location.pathname + window.location.search); + window.dispatchEvent(new HashChangeEvent("hashchange")); + } + window.scrollTo({ top: 0, behavior: "smooth" }); + return; + } + if (item.hash) { + window.location.hash = item.hash; + return; + } + if (item.url) { + if (item.url.startsWith("http")) { + window.open(item.url, "_blank", "noopener,noreferrer"); + } else { + window.location.href = item.url; + } + } + }, + [onClose] + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = filtered[activeIndex]; + if (item) execute(item); + } + }, + [filtered, activeIndex, execute] + ); + + let runningIndex = -1; + + return ( + + + + setQuery(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Search sections, contacts, links…" + fullWidth + variant="standard" + InputProps={{ + disableUnderline: true, + startAdornment: ( + + + + ), + sx: { fontSize: "1rem", color: "#e6edf3" } + }} + inputProps={{ "aria-label": "Command palette search" }} + /> + + + + {filtered.length === 0 ? ( + + + No results for "{query}" + + + ) : ( + grouped.map(([group, items]) => ( + + + {group} + + {items.map((item) => { + runningIndex++; + const isActive = runningIndex === activeIndex; + return ( + execute(item)} + onMouseEnter={() => setActiveIndex(runningIndex)} + sx={{ + display: "flex", + alignItems: "center", + gap: 1.5, + px: 1.5, + py: 1.25, + borderRadius: 1.5, + cursor: "pointer", + background: isActive ? "rgba(14, 173, 223, 0.12)" : "transparent", + border: "1px solid", + borderColor: isActive ? "rgba(14, 173, 223, 0.25)" : "transparent", + transition: "all 0.12s ease", + color: isActive ? "#0eaddf" : "#e6edf3" + }} + > + + {item.icon} + + {item.label} + {item.hint && ( + + )} + {isActive && ( + + )} + + ); + })} + + )) + )} + + + + + + + navigate + + + + open + + + + esc to close + + + + + ); +}; + +export default CommandPalette; diff --git a/src/components/projects/Projects.tsx b/src/components/projects/Projects.tsx new file mode 100644 index 0000000..545059d --- /dev/null +++ b/src/components/projects/Projects.tsx @@ -0,0 +1,262 @@ +import React from "react"; +import { + Box, + Container, + Typography, + Card, + CardContent, + Chip, + Stack, + Button, + Divider +} from "@mui/material"; +import { + CheckCircle as CheckIcon, + Lightbulb as LightbulbIcon, + Build as BuildIcon, + TrendingUp as TrendingUpIcon, + OpenInNew as OpenInNewIcon +} from "@mui/icons-material"; +import { projects, ProjectCaseStudy } from "./projectsData"; + +const SectionTitle: React.FC<{ icon: React.ReactNode; label: string; color: string }> = ({ + icon, + label, + color +}) => ( + + {icon} + + {label} + + +); + +const ProjectCard: React.FC<{ project: ProjectCaseStudy }> = ({ project }) => ( + + + + + + + + {project.role} + + {project.company && ( + <> + · + + {project.company} + + + )} + + + + + {project.title} + + {project.oneLiner} + + + + + + } + label="Problem" + color="#f59e0b" + /> + + {project.problem} + + + + + } + label="Approach" + color={project.accent} + /> + + {project.approach.map((step, i) => ( + + + {i + 1} + + + {step} + + + ))} + + + + + } + label="Result" + color="#22c55e" + /> + + {project.result.map((r, i) => ( + + + + {r} + + + ))} + + + + + {project.stack.map((tech) => ( + + ))} + {project.links?.map((link) => ( + + ))} + + + +); + +const Projects: React.FC = () => { + return ( + + + + + Project case studies + + + Problem · Approach · Result + + + + + {projects.map((p) => ( + + ))} + + + + ); +}; + +export default Projects; diff --git a/src/components/projects/projectsData.ts b/src/components/projects/projectsData.ts new file mode 100644 index 0000000..f102eda --- /dev/null +++ b/src/components/projects/projectsData.ts @@ -0,0 +1,86 @@ +export interface ProjectCaseStudy { + id: string; + title: string; + role: string; + company?: string; + period: string; + oneLiner: string; + problem: string; + approach: string[]; + result: string[]; + stack: string[]; + links?: { label: string; url: string }[]; + accent: string; +} + +// Replace the placeholders below with real numbers and detail before showing +// this section to recruiters. Anything you don't have yet, leave as a TODO so +// it stays visible. + +export const projects: ProjectCaseStudy[] = [ + { + id: "neli-shopify-app", + title: "Shopify merchant app — neliSoftwares", + role: "Software Engineer", + company: "neliSoftwares", + period: "Jul 2025 — Present", + oneLiner: "Embedded Shopify app helping merchants manage [TODO: feature].", + problem: + "Merchants needed [TODO: describe the merchant pain — e.g. faster bulk product edits, theme integrations, custom workflows]. Existing solutions were either too generic or required engineering hours per store.", + approach: [ + "Built the embedded admin UI with Polaris Web Components and App Bridge.", + "Wired Prisma + Postgres for per-shop data; Redis for session and cache.", + "Integrated Shopify GraphQL Admin API with retry + rate-limit handling.", + "Added webhook handlers for shop install / uninstall / data update events." + ], + result: [ + "[TODO: ship metric — e.g. installs, MRR, conversion lift]", + "[TODO: perf metric — e.g. p95 < 500ms]", + "[TODO: any GitHub stars / merchant testimonials]" + ], + stack: ["TypeScript", "React", "Polaris", "Prisma", "Postgres", "Redis", "Shopify"], + accent: "#0eaddf" + }, + { + id: "technixo-frontend", + title: "Frontend rebuild — Technixo", + role: "Frontend Developer", + company: "Technixo", + period: "Dec 2023 — Apr 2024", + oneLiner: "Rewrote the marketing surface from legacy templates to a modern React + TypeScript stack.", + problem: + "Legacy templates were hard to update, slow on mobile, and inconsistent across pages.", + approach: [ + "Migrated the page templates to React + TypeScript with shared layout components.", + "Set up component library and design tokens to keep visual consistency.", + "Optimized images and bundles to hit a healthy Lighthouse score on mobile." + ], + result: [ + "[TODO: lighthouse before / after]", + "[TODO: dev velocity — pages/week, designer review turnaround]" + ], + stack: ["React", "TypeScript", "CSS Modules"], + accent: "#a855f7" + }, + { + id: "true-connect", + title: "Internal dashboard — True Connect", + role: "Frontend Developer", + company: "True Connect", + period: "Jul 2022 — Feb 2023", + oneLiner: "Built dashboards for the operations team to monitor and act on customer data.", + problem: + "Ops were piecing together insights from multiple systems with spreadsheets, which was slow and error-prone.", + approach: [ + "Designed table-heavy screens with virtualization for large data sets.", + "Wired the JS/TS frontend to internal APIs with optimistic updates for common actions.", + "Iterated with the ops team weekly to remove the slowest manual steps." + ], + result: [ + "[TODO: time saved per shift]", + "[TODO: error rate before / after]" + ], + stack: ["JavaScript", "TypeScript"], + accent: "#22c55e" + } +];