diff --git a/.github/scripts/update-pages-list.mjs b/.github/scripts/update-pages-list.mjs deleted file mode 100644 index 5da1c65..0000000 --- a/.github/scripts/update-pages-list.mjs +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env node -// Fetch all repos for $GH_USER with has_pages=true and inject a markdown -// table into content/page/pages/index.md between the PAGES_LIST markers. - -import fs from "node:fs/promises"; -import path from "node:path"; - -const token = process.env.GH_TOKEN; -const user = process.env.GH_USER; - -if (!token || !user) { - console.error("GH_TOKEN and GH_USER env vars are required"); - process.exit(1); -} - -const headers = { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": `${user}-pages-updater`, -}; - -async function fetchAllRepos() { - const all = []; - for (let page = 1; page <= 20; page += 1) { - const url = `https://api.github.com/users/${user}/repos?type=all&sort=updated&per_page=100&page=${page}`; - const res = await fetch(url, { headers }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`GitHub API ${res.status} ${res.statusText}: ${body}`); - } - const batch = await res.json(); - if (!Array.isArray(batch) || batch.length === 0) break; - all.push(...batch); - if (batch.length < 100) break; - } - return all; -} - -function toRow(repo) { - const pagesUrl = - repo.homepage && repo.homepage.trim().length > 0 - ? repo.homepage.trim() - : `https://${user}.github.io/${repo.name}`; - const description = (repo.description || "") - .replace(/\|/g, "\\|") - .replace(/\r?\n/g, " ") - .trim(); - return `| [${repo.name}](${repo.html_url}) | ${description} | [${pagesUrl}](${pagesUrl}) |`; -} - -const START = ""; -const END = ""; - -function buildBlock(rows) { - return [ - START, - "", - "| Name | Description | Pages URL |", - "| --- | --- | --- |", - ...rows, - "", - END, - ].join("\n"); -} - -async function main() { - const repos = await fetchAllRepos(); - const pages = repos - .filter((r) => r.has_pages) - .sort((a, b) => a.name.localeCompare(b.name)); - - const rows = pages.map(toRow); - const block = buildBlock(rows); - - const file = path.resolve("content/page/pages/index.md"); - const current = await fs.readFile(file, "utf8"); - - const marker = /[\s\S]*?/; - const next = marker.test(current) - ? current.replace(marker, block) - : `${current.trimEnd()}\n\n${block}\n`; - - if (next === current) { - console.log(`No changes (${pages.length} pages).`); - return; - } - - await fs.writeFile(file, next); - console.log(`Updated ${file} with ${pages.length} pages.`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/.github/scripts/update-projects-list.mjs b/.github/scripts/update-projects-list.mjs new file mode 100644 index 0000000..c7b0aca --- /dev/null +++ b/.github/scripts/update-projects-list.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// Build a projects list (owned + contributed) and inject it into +// content/page/projects/index.md between the PROJECTS_LIST markers. +// +// A project is included if it has a URL, meaning either: +// - repo.homepage is set, OR +// - repo.has_pages is true (owned repos only — GraphQL does not expose +// has_pages for contributed repos, so those must have homepage set). + +import fs from "node:fs/promises"; +import path from "node:path"; + +const token = process.env.GH_TOKEN; +const user = process.env.GH_USER; + +if (!token || !user) { + console.error("GH_TOKEN and GH_USER env vars are required"); + process.exit(1); +} + +const restHeaders = { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": `${user}-projects-updater`, +}; + +async function fetchOwnedRepos() { + const all = []; + for (let page = 1; page <= 20; page += 1) { + const url = `https://api.github.com/users/${user}/repos?type=all&sort=updated&per_page=100&page=${page}`; + const res = await fetch(url, { headers: restHeaders }); + if (!res.ok) { + throw new Error(`REST /users/${user}/repos ${res.status}: ${await res.text()}`); + } + const batch = await res.json(); + if (!Array.isArray(batch) || batch.length === 0) break; + all.push(...batch); + if (batch.length < 100) break; + } + return all.map((r) => ({ + nameWithOwner: r.full_name, + name: r.name, + htmlUrl: r.html_url, + description: r.description, + homepage: r.homepage, + hasPages: !!r.has_pages, + owner: r.owner?.login, + isOwned: true, + })); +} + +const CONTRIB_QUERY = ` +query($login: String!, $cursor: String) { + user(login: $login) { + repositoriesContributedTo( + first: 100 + after: $cursor + includeUserRepositories: false + privacy: PUBLIC + contributionTypes: [COMMIT, PULL_REQUEST, PULL_REQUEST_REVIEW] + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + pageInfo { hasNextPage endCursor } + nodes { + nameWithOwner + name + url + description + homepageUrl + owner { login } + } + } + } +}`; + +async function fetchContributedRepos() { + const all = []; + let cursor = null; + for (let i = 0; i < 20; i += 1) { + const res = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": `${user}-projects-updater`, + }, + body: JSON.stringify({ + query: CONTRIB_QUERY, + variables: { login: user, cursor }, + }), + }); + if (!res.ok) { + throw new Error(`GraphQL ${res.status}: ${await res.text()}`); + } + const body = await res.json(); + if (body.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(body.errors)}`); + } + const node = body.data?.user?.repositoriesContributedTo; + if (!node) break; + for (const r of node.nodes) { + all.push({ + nameWithOwner: r.nameWithOwner, + name: r.name, + htmlUrl: r.url, + description: r.description, + homepage: r.homepageUrl, + hasPages: false, // not exposed via GraphQL; rely on homepage + owner: r.owner.login, + isOwned: false, + }); + } + if (!node.pageInfo.hasNextPage) break; + cursor = node.pageInfo.endCursor; + } + return all; +} + +function resolveUrl(repo) { + const home = (repo.homepage || "").trim(); + if (home) return home; + if (repo.hasPages && repo.isOwned) { + return `https://${repo.owner}.github.io/${repo.name}`; + } + return null; +} + +function toRow(repo, url) { + const desc = (repo.description || "") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim(); + return `| [${repo.nameWithOwner}](${repo.htmlUrl}) | ${desc} | [${url}](${url}) |`; +} + +const START = ""; +const END = ""; + +function buildBlock(rows) { + return [ + START, + "", + "| Name | Description | URL |", + "| --- | --- | --- |", + ...rows, + "", + END, + ].join("\n"); +} + +async function main() { + const [owned, contributed] = await Promise.all([ + fetchOwnedRepos(), + fetchContributedRepos(), + ]); + + const merged = new Map(); + for (const r of [...owned, ...contributed]) { + if (!merged.has(r.nameWithOwner)) merged.set(r.nameWithOwner, r); + } + + const rows = []; + const sorted = [...merged.values()].sort((a, b) => + a.nameWithOwner.localeCompare(b.nameWithOwner), + ); + for (const r of sorted) { + const url = resolveUrl(r); + if (!url) continue; + rows.push(toRow(r, url)); + } + + const block = buildBlock(rows); + const file = path.resolve("content/page/projects/index.md"); + const current = await fs.readFile(file, "utf8"); + const marker = /[\s\S]*?/; + const next = marker.test(current) + ? current.replace(marker, block) + : `${current.trimEnd()}\n\n${block}\n`; + + if (next === current) { + console.log(`No changes (${rows.length} projects).`); + return; + } + await fs.writeFile(file, next); + console.log(`Updated ${file} with ${rows.length} projects.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/update-pages.yml b/.github/workflows/update-projects.yml similarity index 73% rename from .github/workflows/update-pages.yml rename to .github/workflows/update-projects.yml index 0727201..414666d 100644 --- a/.github/workflows/update-pages.yml +++ b/.github/workflows/update-projects.yml @@ -1,4 +1,4 @@ -name: Update GitHub Pages list +name: Update projects list on: schedule: @@ -10,7 +10,7 @@ permissions: contents: write concurrency: - group: update-pages + group: update-projects cancel-in-progress: false jobs: @@ -30,20 +30,20 @@ jobs: with: node-version: 24 - - name: Refresh pages list + - name: Refresh projects list env: GH_TOKEN: ${{ secrets.PAGES_FETCH_TOKEN }} GH_USER: tiennm99 - run: node .github/scripts/update-pages-list.mjs + run: node .github/scripts/update-projects-list.mjs - name: Commit and push run: | - if [[ -z "$(git status --porcelain content/page/pages/index.md)" ]]; then + if [[ -z "$(git status --porcelain content/page/projects/index.md)" ]]; then echo "No changes to commit." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add content/page/pages/index.md - git commit -m "chore(pages): refresh GitHub Pages list" + git add content/page/projects/index.md + git commit -m "chore(projects): refresh projects list" git push diff --git a/content/page/pages/index.md b/content/page/pages/index.md deleted file mode 100644 index 08eaea3..0000000 --- a/content/page/pages/index.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Pages -description: Danh sách pages đã làm -menu: - main: - weight: -40 - params: - icon: list ---- - -Danh sách những trang mình host trên [GitHub Pages](https://pages.github.com/). Thường khá thú vị :> - -_(Danh sách này được tạo tự động, thứ tự không quan trọng nhé)_ - - - -| Name | Description | Pages URL | -| --- | --- | --- | -| [20200411](https://github.com/tiennm99/20200411) | An old gift website. Archived 2021-02-06. | [https://tiennm99.github.io/20200411/](https://tiennm99.github.io/20200411/) | -| [20221225](https://github.com/tiennm99/20221225) | A small just-for-fun website built on Christmas Day 2022. | [https://tiennm99.github.io/20221225/](https://tiennm99.github.io/20221225/) | -| [apart](https://github.com/tiennm99/apart) | Small personal website with photos and music for someone far away. | [https://tiennm99.github.io/apart/](https://tiennm99.github.io/apart/) | -| [beentogether](https://github.com/tiennm99/beentogether) | Been Together — a small website celebrating time spent together with music and photos. | [https://tiennm99.github.io/beentogether/](https://tiennm99.github.io/beentogether/) | -| [bkfcduchoa.github.io](https://github.com/bkfcduchoa/bkfcduchoa.github.io) | | [https://bkfcduchoa.github.io/](https://bkfcduchoa.github.io/) | -| [cf-worker-telegram](https://github.com/tiennm99/cf-worker-telegram) | A Cloudflare Worker that acts as a transparent proxy for the Telegram Bot API. Useful for bypassing network restrictions or as a middleware for your Telegram bot. | [https://telegram.miti99.workers.dev](https://telegram.miti99.workers.dev) | -| [cowork-complete-guide](https://github.com/tiennm99/cowork-complete-guide) | Practice repo for the Complete Guide to Claude Cowork course | [https://tiennm99.github.io/cowork-complete-guide/](https://tiennm99.github.io/cowork-complete-guide/) | -| [cv](https://github.com/tiennm99/cv) | My personal CV written in Typst with RenderCV. | [https://tiennm99.github.io/cv/](https://tiennm99.github.io/cv/) | -| [demngayxaem](https://github.com/tiennm99/demngayxaem) | Đếm ngày xa em — a small relationship 'days-apart' counter website. | [https://tiennm99.github.io/demngayxaem/](https://tiennm99.github.io/demngayxaem/) | -| [dental-visit](https://github.com/tiennm99/dental-visit) | Small website created for a dental visit appointment. | [https://tiennm99.github.io/dental-visit/](https://tiennm99.github.io/dental-visit/) | -| [dobattu](https://github.com/tiennm99/dobattu) | Website đổi tên quán ăn Ốc Rốt thành Đô Bất Tử | [https://tiennm99.github.io/dobattu/](https://tiennm99.github.io/dobattu/) | -| [double-click-test](https://github.com/tiennm99/double-click-test) | Website to quickly check a mouse for double-click issues. | [https://tiennm99.github.io/double-click-test/](https://tiennm99.github.io/double-click-test/) | -| [exchange-rate-export](https://github.com/tiennm99/exchange-rate-export) | Export BIDV and Techcombank FX rates to Excel — Next.js web app with date range selection | [https://exchange-rate-export.vercel.app/](https://exchange-rate-export.vercel.app/) | -| [hurt-page](https://github.com/tiennm99/hurt-page) | Built/hosted static version of the 'hurt' website. | [https://tiennm99.github.io/hurt-page/](https://tiennm99.github.io/hurt-page/) | -| [latex-cv](https://github.com/tiennm99/latex-cv) | My CV written in LaTeX | [https://tiennm99.github.io/latex-cv/](https://tiennm99.github.io/latex-cv/) | -| [loldle](https://github.com/tiennm99/loldle) | LoLdle-style League of Legends daily champion guessing game — data auto-updated from my loldle-data repo | [https://tiennm99.github.io/loldle/](https://tiennm99.github.io/loldle/) | -| [loto](https://github.com/tiennm99/loto) | Lô Tô (Vietnamese lottery bingo) number board — Next.js web app | [https://tiennm99.github.io/loto/](https://tiennm99.github.io/loto/) | -| [MaiBD2021](https://github.com/tiennm99/MaiBD2021) | Birthday page for Trúc Mai (2021) | [https://tiennm99.github.io/MaiBD2021/](https://tiennm99.github.io/MaiBD2021/) | -| [miti-app-store-scraper](https://github.com/tiennm99/miti-app-store-scraper) | app-store-scraper hosted as serverless functions on Cloudflare Workers | [https://miti-app-store-scraper.vercel.app](https://miti-app-store-scraper.vercel.app) | -| [miti-google-play-scraper](https://github.com/tiennm99/miti-google-play-scraper) | google-play-scraper hosted as serverless functions on Vercel | [https://miti-google-play-scraper.vercel.app](https://miti-google-play-scraper.vercel.app) | -| [ngamtheproject.github.io](https://github.com/ngamtheproject/ngamtheproject.github.io) | Website for Ngăm - a charity organization founded by high school students. | [https://ngamtheproject.github.io/](https://ngamtheproject.github.io/) | -| [nntv](https://github.com/tiennm99/nntv) | Night Ninja: Twilight Voyage — turn-based stealth puzzle game where a ninja rabbit rescues the carrot princess from vegetable guards | [https://tiennm99.github.io/nntv/](https://tiennm99.github.io/nntv/) | -| [ocrot](https://github.com/tiennm99/ocrot) | Website mừng bạn Thân trở thành ông chủ quán ăn Ốc Rốt | [https://tiennm99.github.io/ocrot/](https://tiennm99.github.io/ocrot/) | -| [penny-pincher-provider](https://github.com/tiennm99/penny-pincher-provider) | A curated list of affordable (or almost free) LLM providers for people who are unwilling to pay premium prices for AI. | [https://tiennm99.github.io/penny-pincher-provider/](https://tiennm99.github.io/penny-pincher-provider/) | -| [pikachu](https://github.com/tiennm99/pikachu) | Pikachu tile-matching puzzle game — Phaser 3 + Next.js + TypeScript | [https://tiennm99.github.io/pikachu/](https://tiennm99.github.io/pikachu/) | -| [quycongdongvi](https://github.com/tiennm99/quycongdongvi) | Website của Quỹ cộng đồng Vị | [https://tiennm99.github.io/quycongdongvi/](https://tiennm99.github.io/quycongdongvi/) | -| [sokoban](https://github.com/tiennm99/sokoban) | From-scratch Sokoban puzzle game — Svelte 5 + Vite, all 155 Microban levels, undo, progress tracking | [https://tiennm99.github.io/sokoban/](https://tiennm99.github.io/sokoban/) | -| [styleguide](https://github.com/tiennm99/styleguide) | Style guides for Google-originated open-source projects | [https://google.github.io/styleguide/](https://google.github.io/styleguide/) | -| [survival2d.github.io](https://github.com/survival2d/survival2d.github.io) | Repo to deploy client to GitHub Pages | [https://survival2d.github.io/](https://survival2d.github.io/) | -| [test-deeplink-sms](https://github.com/tiennm99/test-deeplink-sms) | Small test page for verifying SMS deeplink behavior on mobile devices. | [https://tiennm99.github.io/test-deeplink-sms/](https://tiennm99.github.io/test-deeplink-sms/) | -| [thptqg2016](https://github.com/tiennm99/thptqg2016) | Tra cứu điểm thi THPT Quốc gia 2016 — 877.461 thí sinh · Hỗ trợ truy vấn SQL tùy chỉnh | [https://tiennm99.github.io/thptqg2016/](https://tiennm99.github.io/thptqg2016/) | -| [thptqg2017](https://github.com/tiennm99/thptqg2017) | Tra cứu điểm thi THPT Quốc gia 2017 — ~861k thí sinh · Truy vấn SQL client-side với sql.js | [https://tiennm99.github.io/thptqg2017/](https://tiennm99.github.io/thptqg2017/) | -| [thuvienhoangphap](https://github.com/tiennm99/thuvienhoangphap) | Thư Viện Hoằng Pháp — Vietnamese Buddhist dharma library website. | [https://thuvienhoangphap.netlify.app/](https://thuvienhoangphap.netlify.app/) | -| [tiennm99.github.io](https://github.com/tiennm99/tiennm99.github.io) | Redirect page for my personal website | [https://tiennm99.github.io/](https://tiennm99.github.io/) | -| [tienthieusac.github.io](https://github.com/tienthieusac/tienthieusac.github.io) | Tiên Thiếu Sắc | [https://tienthieusac.github.io/](https://tienthieusac.github.io/) | -| [tn1](https://github.com/tiennm99/tn1) | Hugo photo gallery site built on the Gallery Deluxe module | [https://tiennm99.github.io/tn1/](https://tiennm99.github.io/tn1/) | -| [try-bmad](https://github.com/tiennm99/try-bmad) | 2048 puzzle clone built as a practice project using the BMad Method multi-agent framework | [https://tiennm99.github.io/try-bmad/](https://tiennm99.github.io/try-bmad/) | -| [try-claudekit](https://github.com/tiennm99/try-claudekit) | Suika (watermelon) game clone — physics-based fruit-merging puzzle built with ClaudeKit | [https://tiennm99.github.io/try-claudekit/](https://tiennm99.github.io/try-claudekit/) | -| [try-oh-my-claudecode](https://github.com/tiennm99/try-oh-my-claudecode) | OMC Neon Arena — a browser canvas arcade shooter built end-to-end by an oh-my-claudecode multi-agent team (engine, gameplay, UI, docs). | [https://tiennm99.github.io/try-oh-my-claudecode/](https://tiennm99.github.io/try-oh-my-claudecode/) | -| [try-superpowers](https://github.com/tiennm99/try-superpowers) | Practice project exploring the Superpowers workflow to build a Pikachu tile-matching game | [https://tiennm99.github.io/try-superpowers/](https://tiennm99.github.io/try-superpowers/) | -| [Valentine-Vue-Tailwind](https://github.com/tiennm99/Valentine-Vue-Tailwind) | Support @lds217 for Valentine 2025 | [https://tiennm99.github.io/Valentine-Vue-Tailwind/](https://tiennm99.github.io/Valentine-Vue-Tailwind/) | -| [valentine2025](https://github.com/tiennm99/valentine2025) | A fun Valentine's Day 2025 website. | [https://tiennm99.github.io/valentine2025/](https://tiennm99.github.io/valentine2025/) | -| [vigeoguessr](https://github.com/tiennm99/vigeoguessr) | Guess place on map from street view image | [https://vigeoguessr.netlify.app/](https://vigeoguessr.netlify.app/) | -| [vngeoguessr](https://github.com/tiennm99/vngeoguessr) | GeoGuessr clone for Vietnamese locations — Mapillary street view, Nominatim + Turf.js boundaries, Redis leaderboards | [https://vngeoguessr.vercel.app](https://vngeoguessr.vercel.app) | -| [webcv](https://github.com/tiennm99/webcv) | | [https://tiennm99.github.io/webcv/](https://tiennm99.github.io/webcv/) | -| [webgl-heart](https://github.com/tiennm99/webgl-heart) | | [https://tiennm99.github.io/webgl-heart/](https://tiennm99.github.io/webgl-heart/) | - - diff --git a/content/page/projects/index.md b/content/page/projects/index.md new file mode 100644 index 0000000..eb1f524 --- /dev/null +++ b/content/page/projects/index.md @@ -0,0 +1,18 @@ +--- +title: Projects +description: Danh sách projects mình sở hữu hoặc đóng góp +aliases: + - pages +menu: + main: + weight: -40 + params: + icon: list +--- + +Danh sách những projects mình sở hữu hoặc đã đóng góp, có URL demo / homepage. Bao gồm cả [GitHub Pages](https://pages.github.com/) và các project host ở nơi khác. + +_(Danh sách này được tạo tự động, sắp xếp theo tên repo)_ + + +