Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

```
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
85 changes: 85 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,98 @@
name="description"
content="Vũ Xuân Anh — Full Stack Developer. React, TypeScript, Node. Portfolio and contact."
/>
<link rel="canonical" href="https://anhvuFE.github.io/portfolio/" />

<!-- Open Graph -->
<meta property="og:type" content="profile" />
<meta property="og:url" content="https://anhvuFE.github.io/portfolio/" />
<meta property="og:title" content="Vũ Xuân Anh — Full Stack Developer" />
<meta
property="og:description"
content="React, TypeScript, Node. ~3 years across 4 companies, currently shipping at neliSoftwares. Open to freelance and full-time."
/>
<meta property="og:image" content="https://anhvuFE.github.io/portfolio/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Portrait of Vũ Xuân Anh, Full Stack Developer" />
<meta property="og:site_name" content="Vũ Xuân Anh" />
<meta property="og:locale" content="en_US" />
<meta property="profile:first_name" content="Xuân Anh" />
<meta property="profile:last_name" content="Vũ" />
<meta property="profile:username" content="anhvuFE" />

<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vũ Xuân Anh — Full Stack Developer" />
<meta
name="twitter:description"
content="React, TypeScript, Node. Portfolio and contact."
/>
<meta name="twitter:image" content="https://anhvuFE.github.io/portfolio/og-image.png" />
<meta name="twitter:image:alt" content="Portrait of Vũ Xuân Anh, Full Stack Developer" />

<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="%PUBLIC_URL%/favicon-192x192.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

<title>Vũ Xuân Anh — Full Stack Developer</title>

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Vũ Xuân Anh",
"alternateName": ["Vu Xuan Anh", "anhvuFE"],
"jobTitle": "Full Stack Developer",
"url": "https://anhvuFE.github.io/portfolio/",
"image": "https://anhvuFE.github.io/portfolio/og-image.png",
"email": "mailto:vuxuananh22@gmail.com",
"sameAs": [
"https://github.com/anhvuFE",
"https://www.linkedin.com/in/xu%C3%A2n-anh-v%C5%A9-515580367/",
"https://www.facebook.com/xuananhvu2312/"
],
"knowsAbout": [
"React",
"TypeScript",
"JavaScript",
"Node.js",
"Next.js",
"Material UI",
"Shopify",
"PostgreSQL",
"MongoDB",
"Docker"
],
"address": {
"@type": "PostalAddress",
"addressLocality": "Hanoi",
"addressCountry": "VN"
},
"worksFor": {
"@type": "Organization",
"name": "neliSoftwares"
},
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "FPT University"
}
}
</script>

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Vũ Xuân Anh — Portfolio",
"url": "https://anhvuFE.github.io/portfolio/",
"author": {
"@type": "Person",
"name": "Vũ Xuân Anh"
}
}
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this portfolio.</noscript>
Expand Down
Binary file added public/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
151 changes: 151 additions & 0 deletions scripts/fetch-github-activity.js
Original file line number Diff line number Diff line change
@@ -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
});
101 changes: 101 additions & 0 deletions scripts/generate-og-image.js
Original file line number Diff line number Diff line change
@@ -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(
`<svg width="${AVATAR_SIZE}" height="${AVATAR_SIZE}"><rect x="0" y="0" width="${AVATAR_SIZE}" height="${AVATAR_SIZE}" rx="40" ry="40" fill="white"/></svg>`
),
blend: "dest-in"
}
])
.png()
.toBuffer();

const avatarX = PAD;
const avatarY = Math.round((H - AVATAR_SIZE) / 2);
const textX = avatarX + AVATAR_SIZE + 56;

const svg = `
<svg width="${W}" height="${H}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="glow" cx="80%" cy="20%" r="50%">
<stop offset="0%" stop-color="${ACCENT}" stop-opacity="0.18"/>
<stop offset="100%" stop-color="${ACCENT}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${W}" height="${H}" fill="${BG}"/>
<rect width="${W}" height="${H}" fill="url(#glow)"/>

<text x="${textX}" y="${avatarY + 80}" font-family="-apple-system, system-ui, sans-serif"
font-size="22" font-weight="600" fill="${ACCENT}" letter-spacing="3">
FULL STACK DEVELOPER
</text>

<text x="${textX}" y="${avatarY + 170}" font-family="-apple-system, system-ui, sans-serif"
font-size="78" font-weight="800" fill="${TEXT_PRIMARY}" letter-spacing="-2">
Vũ Xuân Anh
</text>

<text x="${textX}" y="${avatarY + 235}" font-family="-apple-system, system-ui, sans-serif"
font-size="28" font-weight="500" fill="${TEXT_MUTED}">
React · TypeScript · Node
</text>

<line x1="${textX}" y1="${avatarY + 290}" x2="${textX + 80}" y2="${avatarY + 290}"
stroke="${ACCENT}" stroke-width="3"/>

<text x="${textX}" y="${avatarY + 340}" font-family="-apple-system, system-ui, sans-serif"
font-size="22" font-weight="500" fill="${TEXT_MUTED}">
Hanoi, Vietnam · open to freelance &amp; full-time
</text>

<text x="${PAD}" y="${H - 40}" font-family="ui-monospace, monospace"
font-size="18" font-weight="500" fill="${TEXT_MUTED}">
anhvuFE.github.io/portfolio
</text>

<text x="${W - PAD}" y="${H - 40}" font-family="ui-monospace, monospace"
font-size="18" font-weight="500" fill="${ACCENT}" text-anchor="end">
github.com/anhvuFE
</text>
</svg>
`;

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);
});
Loading
Loading