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
+
+
+
+
You need to enable JavaScript to run this portfolio.
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) => (
+ }
+ sx={{
+ ml: "auto",
+ color: project.accent,
+ fontSize: "0.75rem",
+ textTransform: "none",
+ "&:hover": { background: `${project.accent}10` }
+ }}
+ >
+ {link.label}
+
+ ))}
+
+
+
+);
+
+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"
+ }
+];