From 333ac6b9ae24acf198c602b5f7532678c613912d Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Sun, 12 Jul 2026 02:39:15 -0700 Subject: [PATCH 1/8] feat: GitHub as the single source of truth for project cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desc/summary mirror the repo About, stack/tags mirror repo Topics, stars stay live — all fetched with the same 120s revalidate window; hand-written values remain only as API-failure fallbacks (synced) - Course Projects card is GitHub-backed too (Kai-Course-Notes) and now shows its live star count - SudoSodoku and buttercut cards link to their live sites (sudosodoku.kaichen.dev, buttercut.kaichen.dev); destination hint label now reflects the actual host instead of hardcoded GitHub Co-Authored-By: Claude Fable 5 Co-authored-by: Claude --- .../oxford-dul/course-project-link.tsx | 4 +- app/components/oxford-dul/projects-split.tsx | 6 +- app/components/pinned-project-link.tsx | 4 +- app/lib/course-projects.ts | 67 ++++++++++++++++++- app/lib/personal-projects.ts | 30 ++++++--- app/page.tsx | 9 ++- app/projects/page.tsx | 5 +- 7 files changed, 104 insertions(+), 21 deletions(-) diff --git a/app/components/oxford-dul/course-project-link.tsx b/app/components/oxford-dul/course-project-link.tsx index 4f9f12c..676e238 100644 --- a/app/components/oxford-dul/course-project-link.tsx +++ b/app/components/oxford-dul/course-project-link.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { HoverLinkArrow, HoverLinkDestinationHint } from "../hover-link-hint"; +import ProjectStars from "../project-stars"; import type { CourseProjectEntry } from "@/app/lib/course-projects"; type CourseProjectLinkProps = { @@ -8,7 +9,7 @@ type CourseProjectLinkProps = { }; export default function CourseProjectLink({ entry, variant = "list" }: CourseProjectLinkProps) { - const { href, institution, title, grade, summary, tags, external, hintLabel } = entry; + const { href, institution, title, grade, summary, tags, external, hintLabel, stars } = entry; const nameStyle = { fontFamily: "var(--font-nunito)", @@ -30,6 +31,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro

{title}

+ {grade ? ( - {COURSE_PROJECTS.map((entry) => ( + {courseProjects.map((entry) => ( ))} - + ); @@ -84,7 +84,7 @@ export default function PinnedProjectLink({

- + {desc ? (

{ + const token = process.env.GITHUB_TOKEN; + + return Promise.all( + COURSE_PROJECTS.map(async (entry) => { + if (!entry.repo) return entry; + try { + const res = await fetch(`https://api.github.com/repos/${entry.repo}`, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + next: { revalidate: REVALIDATE_SECONDS, tags: ["github-stars"] }, + }); + if (!res.ok) return entry; + const json: { + description?: string | null; + topics?: string[]; + stargazers_count?: number; + } = await res.json(); + return { + ...entry, + summary: json.description ?? entry.summary, + tags: json.topics && json.topics.length > 0 ? json.topics : entry.tags, + stars: + typeof json.stargazers_count === "number" ? json.stargazers_count : entry.stars, + }; + } catch { + return entry; + } + }) + ); +}); diff --git a/app/lib/personal-projects.ts b/app/lib/personal-projects.ts index 7ee1bd3..81f53cf 100644 --- a/app/lib/personal-projects.ts +++ b/app/lib/personal-projects.ts @@ -12,8 +12,9 @@ export type PersonalProject = { }; /** - * Personal projects shown on / and /projects — maintained by hand. - * Add, remove, or reorder entries here; only star counts are fetched live. + * Personal projects shown on / and /projects. Each entry mirrors its GitHub + * repo: desc = About, stack = Topics, stars = live count — all fetched live; + * the values below are fallbacks kept in sync by hand. */ export const PERSONAL_PROJECTS: PersonalProject[] = [ { @@ -21,28 +22,28 @@ export const PERSONAL_PROJECTS: PersonalProject[] = [ desc: "my personal space at a corner of human made internet :D @kaiiiichen", href: "https://github.com/kaiiiichen/kaichen.dev", repo: "kaiiiichen/kaichen.dev", - stack: ["TypeScript", "CSS", "Python", "Shell", "JavaScript"], + stack: ["app-router", "nextjs", "personal-website", "react", "tailwindcss", "typrescript"], }, { name: "SudoSodoku", - desc: "A terminal-style Sudoku experience for iOS, designed for logical purists.", - href: "https://github.com/SudoSodokuApp/SudoSodoku", + desc: "sudo solve — a terminal-fantasy Sudoku for iOS.", + href: "https://sudosodoku.kaichen.dev/", repo: "SudoSodokuApp/SudoSodoku", - stack: ["Swift", "Shell"], + stack: ["combine", "game", "gamekit", "ios", "ios-app", "mvvm", "puzzle-game", "sudoku", "swift", "swiftui"], }, { name: "buttercut", desc: "Buttercut is a personal-site theme for Next.js (App Router, React 19) that wants you to ship something beautiful without wrestling the codebase every weekend.", - href: "https://github.com/kaiiiichen/buttercut", + href: "https://buttercut.kaichen.dev/", repo: "kaiiiichen/buttercut", - stack: ["TypeScript", "CSS", "JavaScript"], + stack: ["app-router", "nextjs", "personal-website", "react", "tailwindcss", "template", "theme", "typrescript"], }, { name: "WatchTower-AI", desc: "[UC Berkeley AI Hackathon 2026] Is the AI down, or is it you? WatchTower AI probes Claude, GPT & Gemini in real time, tells you whose fault it is, and catches outages before the official status page does.", href: "https://github.com/kaiiiichen/WatchTower-AI", repo: "kaiiiichen/WatchTower-AI", - stack: ["Python", "TypeScript", "CSS", "JavaScript", "Dockerfile"], + stack: [], }, ]; @@ -68,10 +69,17 @@ export const getPersonalProjects = cache(async function getPersonalProjects(): P next: { revalidate: STARS_REVALIDATE_SECONDS, tags: ["github-stars"] }, }); if (!res.ok) return null; - const json: { stargazers_count?: number; archived?: boolean } = await res.json(); + const json: { + stargazers_count?: number; + archived?: boolean; + description?: string | null; + topics?: string[]; + } = await res.json(); return { stars: typeof json.stargazers_count === "number" ? json.stargazers_count : undefined, archived: json.archived, + desc: json.description ?? undefined, + stack: json.topics, }; } catch { return null; @@ -83,5 +91,7 @@ export const getPersonalProjects = cache(async function getPersonalProjects(): P ...project, stars: live[i]?.stars ?? project.stars, archived: live[i]?.archived ?? project.archived, + desc: live[i]?.desc ?? project.desc, + stack: live[i]?.stack && live[i]!.stack!.length > 0 ? live[i]!.stack! : project.stack, })); }); diff --git a/app/page.tsx b/app/page.tsx index 3a24409..2b54f9e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import JumpText from "./components/jump-text"; import ListeningCard from "./components/listening-card"; import WeatherCard from "./components/weather-card"; import ProjectsSplit from "./components/oxford-dul/projects-split"; +import { getCourseProjects } from "./lib/course-projects"; import IdentityRow from "./components/identity-row"; import { TocSection } from "./components/toc-section"; import { getPersonalProjects } from "./lib/personal-projects"; @@ -36,7 +37,11 @@ const PERSON_JSON_LD = { } as const; export default async function Home() { - const [projects, weather] = await Promise.all([getPersonalProjects(), getBerkeleyWeather()]); + const [projects, courseProjects, weather] = await Promise.all([ + getPersonalProjects(), + getCourseProjects(), + getBerkeleyWeather(), + ]); return (