diff --git a/.env.example b/.env.example index f819923..3689356 100644 --- a/.env.example +++ b/.env.example @@ -25,8 +25,10 @@ LASTFM_API_KEY= # --- GitHub ------------------------------------------------------------------ # Personal access token or fine-grained token with repo read access. -# Powers /api/github/contributions and /api/github/stars. +# Powers /api/github/contributions, /api/github/stars, and pinned repos on the site. GITHUB_TOKEN= +# Optional: GitHub username for pinned repos + activity (default: kaiiiichen). +GITHUB_LOGIN= # --- Sentry (optional) ------------------------------------------------------- # Omit entirely for local dev / CI with no error reporting. diff --git a/README.md b/README.md index d77f57a..b465f90 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,9 @@ This repository is a **[Next.js 16](https://nextjs.org/)** application using the The site combines: -- A **marketing-style home page** (identity, listening status, weather, projects, Substack headlines). -- **Dynamic data** from Last.fm, GitHub, Open-Meteo, and optional Supabase-backed gallery and listening history. -- **MDX-powered notes** under `/notes` with math (KaTeX), GitHub-flavored Markdown, and syntax-highlighted code blocks. +- A **marketing-style home page** (identity, listening status, weather, **GitHub pinned repositories** via GraphQL with a static fallback, Substack headlines, and links to **news.kaichen.dev** and the **Berkeley library hours** page). +- **Dynamic data** from Last.fm, GitHub, Open-Meteo, optional Supabase-backed gallery and listening history, and **UC Berkeley library hours** scraped from the official hours page (cached, JSON API available). +- **MDX-powered notes** under `/notes` with math (KaTeX), GitHub-flavored Markdown, and syntax-highlighted code blocks — multiple course segments (UC Berkeley and SUSTech); see [MDX lecture notes](#mdx-lecture-notes). - **Optional observability** via Sentry (client, server, edge) and Vercel Analytics / Speed Insights. There is **no** `middleware.ts` in this repo; auth for admin flows uses Supabase OAuth and route handlers under `app/auth/`. @@ -162,15 +162,17 @@ kaichen.dev/ │ ├── notes/ # Notes index, course pages, MDX note routes │ ├── gallery/ # Public gallery + OG │ ├── admin/ # Supabase-auth gallery admin; /admin/gallery → redirect /admin -│ ├── api/ # Route handlers (Last.fm, GitHub, weather, guestbook) +│ ├── api/ # Route handlers (Last.fm, GitHub, weather, guestbook, UCB libraries) +│ ├── berkeley-libraries/ # UC Berkeley library hours (HTML from lib.berkeley.edu) │ ├── auth/callback/ # Supabase OAuth exchange → redirect │ ├── components/ # UI: nav, cards, theme, weather, listening, GitHub heatmap, … │ ├── hooks/ # e.g. use-now-playing.ts -│ └── lib/ # og.tsx, substack RSS helpers +│ └── lib/ # og.tsx, substack RSS, GitHub pinned repos (GraphQL) ├── components/notes/ # MDX shortcodes: Theorem, Proof, Definition, Example, NoteBlock ├── lib/ # Shared server-oriented helpers + Vitest tests │ ├── supabase.ts # Lazy anon Supabase client (getSupabaseAnon) │ ├── now-playing.ts # Types for Last.fm payload +│ ├── ucb-library-hours.ts # Fetch + parse lib.berkeley.edu/hours (Cheerio) │ ├── weather-open-meteo.ts │ └── *.test.ts ├── mdx-components.tsx # MDX element mapping + shortcode registration @@ -205,6 +207,7 @@ kaichen.dev/ | Framework | Next.js **16.2** (App Router), React **19**, TypeScript **5** | | Styling | Tailwind CSS **4** (`@tailwindcss/postcss`), custom CSS in `app/globals.css` | | Content | **MDX** via `@mdx-js/loader` + `remark-gfm`, `remark-math`, `rehype-katex`, `rehype-highlight` | +| Scraping | **cheerio** — parses UC Berkeley library hours HTML server-side | | Fonts | `@fontsource/*` (Nunito, Bitter, JetBrains Mono), `geist` (sans/mono CSS variables) | | Auth / data | Supabase (`@supabase/supabase-js`, `@supabase/ssr`) for OAuth, gallery, optional listening DB writes | | Monitoring | `@sentry/nextjs` (optional DSN), Vercel Analytics + Speed Insights | @@ -218,16 +221,17 @@ Pinned versions are in [`package.json`](package.json). | Route | What it does | | --- | --- | -| `/` | Identity block, Last.fm line + card, Berkeley weather, project list with live GitHub stars, Substack RSS snippets | +| `/` | Identity block, Last.fm line + card, Berkeley weather, **pinned GitHub repos** (GraphQL + fallback list), Substack RSS snippets, links to **news.kaichen.dev** and **`/berkeley-libraries`** | | `/about` | Education, experience, courses, volunteering | | `/projects` | Project cards + **GitHub contribution calendar** (client component, data from `/api/github/contributions`) | -| `/notes` | Index of courses / note collections | -| `/notes/...` | Nested segments; individual notes are often `page.mdx` (e.g. CS61A Scheme topics) | +| `/notes` | Index of courses (see [MDX lecture notes](#mdx-lecture-notes)); links to external [SUSTech-Kai-Notes](https://github.com/kaiiiichen/SUSTech-Kai-Notes) for broader collections | +| `/notes/...` | Nested segments per course (e.g. `cs61a`, `data100`, `cs217`, `ma121`–`ma337`); individual notes are `page.mdx` | +| `/berkeley-libraries` | UC Berkeley library **open/closed** status and hours, parsed from [lib.berkeley.edu/hours](https://www.lib.berkeley.edu/hours); data revalidates every **15 minutes** | | `/gallery` | Photo grid + lightbox; data from Supabase `gallery_photos` + Storage | | `/admin` | Google OAuth via Supabase; **restricted to an allowlisted email** in client code — **you must enforce the same rules in Supabase RLS** for production safety | | `/admin/gallery` | Redirects to `/admin` | -The main nav **Blog** link points to external [Substack](https://substack.com/@kaiiiichen); there is no `/blog` route in-app. +**External nav (no in-app route):** the main nav includes **News** → [news.kaichen.dev](https://news.kaichen.dev) and **Blog** → [Substack](https://kaiiiichen.substack.com/); there is no `/blog` or `/news` route in this repo. **Open Graph:** several routes ship `opengraph-image` route handlers for social previews. Set [`metadataBase`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabase) in `app/layout.tsx` if you see build warnings about resolving OG image URLs. @@ -243,10 +247,13 @@ All handlers live under `app/api/`. | `GET /api/github/contributions` | GraphQL contribution calendar + REST search for latest commit + REST repo metadata for star counts | `dynamic = force-dynamic`; `Cache-Control: no-store`; requires `GITHUB_TOKEN` | | `GET /api/github/stars?repo=owner/name` | Returns `stargazers_count` and `archived` for a repo | `revalidate = 3600`; optional `GITHUB_TOKEN` for rate limits | | `GET /api/weather` | Open-Meteo forecast for fixed Berkeley coordinates | `fetch` with `next.revalidate = 600` | +| `GET /api/ucb-libraries` | Same payload as `/berkeley-libraries`: JSON with `libraries`, `fetchedAt`, `sourceUrl`, or `ok: false` + `error` | Uses `getUCBLibraryHours()`; upstream fetch `revalidate: 900` (15 minutes) | | `POST /api/guestbook` | JSON body `{ email, message }` → insert into Supabase `guestbook` via anon client | No auth; relies on **Supabase RLS** and sensible limits in the database | **Guestbook** is only referenced from API + docs; ensure any front-end or future form respects abuse concerns (rate limits, validation) at the edge or in Supabase policies. +**Berkeley libraries:** parsing depends on the HTML structure of lib.berkeley.edu. If the upstream page changes, [`lib/ucb-library-hours.ts`](lib/ucb-library-hours.ts) may need updates (see error responses when zero libraries parse). + --- ## Environment variables @@ -262,7 +269,8 @@ Copy [`.env.example`](.env.example) to `.env.local`. **Never commit** real secre | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase anonymous key (browser + server routes using `getSupabaseAnon()`). | | `SUPABASE_SERVICE_ROLE_KEY` | **Server-only.** Used by `/api/lastfm/now-playing` for DB writes and any server path that must bypass RLS — keep off the client bundle. | | `LASTFM_API_KEY` | Last.fm API. If unset, the now-playing API returns a graceful “not playing” / DB fallback without calling Last.fm. | -| `GITHUB_TOKEN` | Fine-grained or classic PAT for GitHub API (contributions + stars). If missing, some features error or return empty data. | +| `GITHUB_TOKEN` | Fine-grained or classic PAT for GitHub API (contributions + stars + **pinned repos** on the home page). If missing, contribution/stars features may error or return empty data; pinned projects fall back to a **static list** in [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts). | +| `GITHUB_LOGIN` | Optional. GitHub username for **pinned repositories** and related API calls (defaults to `kaiiiichen` if unset). Set when forking so the home page shows your pins. | ### Sentry (optional) @@ -288,12 +296,14 @@ That path is gitignored — do not commit it. ## MDX lecture notes - Notes are **route segments** with `page.mdx` files (e.g. `app/notes/cs61a/scheme-quote/page.mdx`), not a separate `content/` directory. +- The index at [`/notes`](app/notes/page.tsx) lists courses by code (examples: **CS61A**, **Data 100**, **CS217**, **MA121**–**MA337**). Each course has a `page.tsx` hub and nested folders for individual notes. - Shared layout: [`app/notes/layout.tsx`](app/notes/layout.tsx) (imports KaTeX CSS, width/padding). - MDX components and typography are centralized in [`mdx-components.tsx`](mdx-components.tsx). - Custom shortcodes (Theorem, Definition, Proof, Example, NoteBlock) live in [`components/notes/`](components/notes/) and are registered globally for MDX. - Metadata in MDX files often uses `export const metadata = { title, description }` (Next.js metadata), not always YAML frontmatter. +- Large PDFs or archives for a course may live under `public/notes/...` and be linked from MDX; keep binary paths in sync if you move files. -To add a new note: create a folder + `page.mdx` under `app/notes/`, match existing note headers (breadcrumb, title block) for visual consistency, and run `npm run build` to validate the MDX pipeline. +To add a new course: add a card on the notes index, create `app/notes//page.tsx` plus note folders with `page.mdx`, match existing note headers (breadcrumb, title block) for visual consistency, and run `npm run build` to validate the MDX pipeline. --- @@ -308,6 +318,7 @@ To add a new note: create a folder + `page.mdx` under `app/notes/`, match existi | **Open-Meteo** | Weather (no API key) | | **Supabase** | Auth, gallery tables + storage, guestbook insert, listening history (optional) | | **Substack RSS** | Home page “latest posts” (`app/lib/substack.ts`) | +| **lib.berkeley.edu** | Library hours HTML (scraped server-side; not an official API) | --- @@ -422,7 +433,8 @@ Replace at minimum: | --- | --- | | Copy, links, projects list | `app/page.tsx`, `app/projects/page.tsx`, `app/about/page.tsx` | | Last.fm username | `app/api/lastfm/now-playing/route.ts` | -| GitHub login / repos | `app/api/github/contributions/route.ts`, `app/components/project-stars.tsx`, home page `PROJECTS` | +| GitHub login / repos / pins | `app/api/github/contributions/route.ts`, `app/components/project-stars.tsx`, [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts), env `GITHUB_LOGIN` | +| Berkeley library page | `lib/ucb-library-hours.ts`, `app/berkeley-libraries/page.tsx`, `app/api/ucb-libraries/route.ts` | | Supabase project + admin allowlist | `lib/supabase.ts`, `app/admin/page.tsx`, Supabase dashboard (RLS, Storage) | | Substack feeds | `app/lib/substack.ts` | | Weather location | `app/api/weather/route.ts`, weather UI components | diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 5e1efd4..1ec447a 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -70,10 +70,17 @@ export default function Admin() { }, [user]); async function signIn() { - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? location.origin; + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin; + let nextPath = "/admin"; + const fromQuery = new URLSearchParams(window.location.search).get("next"); + if (fromQuery && fromQuery.startsWith("/") && !fromQuery.startsWith("//")) { + nextPath = fromQuery; + } + const callback = new URL("/auth/callback", siteUrl); + callback.searchParams.set("next", nextPath); await supabase.auth.signInWithOAuth({ provider: "google", - options: { redirectTo: `${siteUrl}/auth/callback?next=/admin` }, + options: { redirectTo: callback.toString() }, }); } diff --git a/app/api/github/contributions/route.ts b/app/api/github/contributions/route.ts index 1a4615b..0d29434 100644 --- a/app/api/github/contributions/route.ts +++ b/app/api/github/contributions/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { getPinnedProjects, GITHUB_PROFILE_LOGIN } from "@/app/lib/github-pinned"; export const dynamic = "force-dynamic"; @@ -38,12 +39,8 @@ export async function GET() { const from = new Date(now); from.setFullYear(now.getFullYear() - 1); - const STAR_REPOS = [ - "kaiiiichen/kaichen-dev", - "kaiiiichen/SUSTech-Kai-Notes", - "kaiiiichen/SudoSodoku", - "kaiiiichen/kai-chen.xyz", - ]; + const pinned = await getPinnedProjects(); + const STAR_REPOS = pinned.map((p) => p.repo); const [graphqlRes, commitsRes, ...starResults] = await Promise.all([ fetch("https://api.github.com/graphql", { @@ -56,14 +53,14 @@ export async function GET() { body: JSON.stringify({ query: QUERY, variables: { - login: "kaiiiichen", + login: GITHUB_PROFILE_LOGIN, from: from.toISOString(), to: now.toISOString(), }, }), }), fetch( - "https://api.github.com/search/commits?q=author:kaiiiichen&sort=author-date&order=desc&per_page=1", + `https://api.github.com/search/commits?q=author:${GITHUB_PROFILE_LOGIN}&sort=author-date&order=desc&per_page=1`, { cache: "no-store", headers: { diff --git a/app/api/github/stars/route.ts b/app/api/github/stars/route.ts index c86cd96..a8d9c15 100644 --- a/app/api/github/stars/route.ts +++ b/app/api/github/stars/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -export const revalidate = 3600; // cache 1 hour +/** ISR: refresh repo metadata often enough for archive / visibility to stay accurate */ +export const revalidate = 120; + +const cacheHeaders = { + "Cache-Control": "public, s-maxage=120, stale-while-revalidate=60", +}; export async function GET(req: NextRequest) { const repo = req.nextUrl.searchParams.get("repo"); @@ -12,14 +17,27 @@ export async function GET(req: NextRequest) { Accept: "application/vnd.github+json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - next: { revalidate: 3600 }, + next: { revalidate: 120 }, }); - if (!res.ok) return NextResponse.json({ stars: 0 }); + if (!res.ok) { + return NextResponse.json({ stars: 0 }, { headers: cacheHeaders }); + } const data = await res.json(); - return NextResponse.json({ - stars: data.stargazers_count ?? 0, - archived: data.archived ?? false, - }); + const visibility = + typeof data.visibility === "string" + ? (data.visibility as "public" | "private") + : data.private + ? "private" + : "public"; + + return NextResponse.json( + { + stars: data.stargazers_count ?? 0, + archived: data.archived ?? false, + visibility, + }, + { headers: cacheHeaders } + ); } diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 457df3e..e18c149 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -2,10 +2,17 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; +/** OAuth may pass `next=` empty; `?? "/admin"` does not catch "", which redirects to site root. */ +function safeNextPath(raw: string | null): string { + if (raw == null || raw === "") return "/admin"; + if (!raw.startsWith("/") || raw.startsWith("//")) return "/admin"; + return raw; +} + export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); - const next = searchParams.get("next") ?? "/admin"; + const next = safeNextPath(searchParams.get("next")); // On Vercel, request.url uses localhost internally — use x-forwarded-host for the real domain const forwardedHost = request.headers.get("x-forwarded-host"); diff --git a/app/berkeley-libraries/page.tsx b/app/berkeley-libraries/page.tsx index f6385bc..7cef2ed 100644 --- a/app/berkeley-libraries/page.tsx +++ b/app/berkeley-libraries/page.tsx @@ -6,7 +6,7 @@ import type { UCBLibrary } from "@/lib/ucb-library-hours"; export const metadata: Metadata = { title: "UC Berkeley Library Hours", description: - "Live open/closed status for UC Berkeley libraries, from lib.berkeley.edu (refreshed at most every 15 minutes).", + "Library hours parsed from lib.berkeley.edu/hours; fetch cache revalidates every 15 minutes (Next.js revalidate: 900).", openGraph: { title: "UC Berkeley Library Hours · Kai Chen", description: "Library availability scraped from the official Berkeley hours page.", @@ -106,7 +106,7 @@ export default async function BerkeleyLibrariesPage() { style={{ fontFamily: "'Bitter'", fontWeight: 400, fontSize: 16, lineHeight: 1.75 }} className="text-zinc-600 dark:text-zinc-400 mt-4 max-w-2xl" > - Parsed from the official{" "} + The server fetches the public{" "} library hours {" "} - page. Data is cached for up to 15 minutes to avoid hammering their servers. Overnight and - special hours may only appear on each library's detail page — check the official site - when in doubt. + HTML and parses list items server-side: each library gets a name, link to its visit page, + and—when the list text matches simple time patterns—a same-day hours window and an + open/closed guess. The underlying HTTP fetch is cached by Next.js with{" "} + revalidate: 900 (15 minutes), so the live + lib.berkeley.edu page is not downloaded on every request. Lines that don't match those + patterns (unusual wording, overnight spans, holidays) may parse as unknown or incomplete—check + the official site or each library's page when it matters.

diff --git a/app/components/nav.tsx b/app/components/nav.tsx index 207731c..44d1387 100644 --- a/app/components/nav.tsx +++ b/app/components/nav.tsx @@ -17,11 +17,16 @@ const NAV_LINKS = [ ]; export default function Nav() { + const [mounted, setMounted] = useState(false); const [isOpen, setIsOpen] = useState(false); const navRef = useRef(null); const [waveRect, setWaveRect] = useState<{ left: number; width: number; top: number } | null>(null); const waveLeaveFrame = useRef(0); + useEffect(() => { + queueMicrotask(() => setMounted(true)); + }, []); + const onWaveEnter = useCallback((e: React.MouseEvent) => { const el = e.currentTarget; const r = el.getBoundingClientRect(); @@ -157,7 +162,7 @@ export default function Nav() { - {typeof document !== "undefined" + {mounted ? createPortal(
(null); useEffect(() => { - fetch(`/api/github/stars?repo=${encodeURIComponent(repo)}`) + fetch(`/api/github/stars?repo=${encodeURIComponent(repo)}`, { cache: "no-store" }) .then((r) => r.json()) .then((d) => { if (typeof d.stars === "number") { - setMeta({ stars: d.stars, archived: d.archived ?? false }); + const vis = d.visibility; + setMeta({ + stars: d.stars, + archived: d.archived ?? false, + ...(vis === "public" || vis === "private" ? { visibility: vis } : {}), + }); } }) .catch(() => {}); @@ -19,13 +28,23 @@ export default function ProjectStars({ repo }: { repo: string }) { if (meta === null) return null; + const badgeClass = + "px-1.5 py-0.5 rounded-sm bg-zinc-100 dark:bg-zinc-800 text-zinc-400 dark:text-zinc-500"; + return ( <> + {meta.visibility === "public" && ( + + public + + )} + {meta.visibility === "private" && ( + + private + + )} {meta.archived && ( - + archived )} diff --git a/app/gallery/page.tsx b/app/gallery/page.tsx index 09679d8..8de2950 100644 --- a/app/gallery/page.tsx +++ b/app/gallery/page.tsx @@ -205,7 +205,7 @@ export default function Gallery() { {/* Admin entry — bottom, minimal icon */}
diff --git a/app/layout.tsx b/app/layout.tsx index 7e8e0a9..6c82cf5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -51,9 +51,13 @@ export default function RootLayout({ suppressHydrationWarning > - +