From a159af76b45446eb66990b5003f416946f804b22 Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Sat, 4 Jul 2026 22:05:29 -0700 Subject: [PATCH] feat: homepage TOC, Music for Life URL, and loading polish Add PageToc to the home page and Music for Life routes (title-level sections only on playlist detail). Rename public URLs from /playlists to /music-for-life with permanent redirects. Improve initial load via Nunito next/font, weather SSR, dynamic Mapbox, GitHub prefetch, and route loading states. Update README, AGENTS.md, and CLAUDE.md. Co-authored-by: Claude Co-authored-by: Cursor --- .env.example | 2 +- AGENTS.md | 35 +++ CLAUDE.md | 8 +- README.md | 46 ++-- app/about/page.tsx | 38 +-- app/api/github/contributions/route.ts | 65 +---- app/api/weather/route.ts | 29 +- app/components/GitHubActivity.tsx | 30 +-- app/components/hover-link-hint.tsx | 43 ++- app/components/hover-tip.tsx | 6 +- app/components/listening-card.tsx | 9 +- app/components/listening-last-month-top.tsx | 72 +++-- app/components/listening-track-row.tsx | 4 +- app/components/nav.tsx | 6 +- .../oxford-dul/course-project-link.tsx | 14 +- app/components/oxford-dul/disclaimer.tsx | 2 +- app/components/oxford-dul/image-gallery.tsx | 4 +- app/components/oxford-dul/learning-path.tsx | 6 +- app/components/oxford-dul/project-card.tsx | 8 +- .../oxford-dul/project-sections.tsx | 2 +- app/components/oxford-dul/projects-split.tsx | 2 +- app/components/page-toc.tsx | 251 ++++++++++++++++++ app/components/pinned-project-link.tsx | 10 +- app/components/playlist-description.tsx | 2 +- app/components/playlist-map-loader.tsx | 27 ++ app/components/playlist-map.tsx | 4 +- app/components/project-stars.tsx | 4 +- app/components/site-footer.tsx | 21 +- app/components/subpage-enter.tsx | 14 +- app/components/toc-section.tsx | 43 +++ app/components/weather-card.tsx | 39 +-- app/fonts.ts | 10 + app/globals.css | 183 ++++++++++++- app/layout.tsx | 9 +- app/lib/github-contributions.ts | 72 +++++ app/lib/spotify-playlists.ts | 6 +- app/lib/weather.ts | 26 ++ app/misc/page.tsx | 8 +- app/music-for-life/[id]/loading.tsx | 31 +++ app/music-for-life/[id]/page.tsx | 151 +++++++++++ app/music-for-life/loading.tsx | 32 +++ .../map/layout.tsx | 0 .../map/map-viewport-lock.tsx | 0 .../map/page.tsx | 15 +- .../opengraph-image.tsx | 0 app/{playlists => music-for-life}/page.tsx | 29 +- app/page.tsx | 145 +++++----- app/playlists/[id]/page.tsx | 145 ---------- app/projects/oxford-dul-2025/[slug]/page.tsx | 6 +- app/projects/oxford-dul-2025/page.tsx | 28 +- app/projects/page.tsx | 17 +- app/sitemap.ts | 5 +- lib/mapbox-playlist-map.ts | 2 +- lib/spotify-playlists.test.ts | 4 +- lib/spotify-playlists.ts | 5 +- next.config.ts | 14 + package-lock.json | 20 -- package.json | 2 - 58 files changed, 1249 insertions(+), 562 deletions(-) create mode 100644 app/components/page-toc.tsx create mode 100644 app/components/playlist-map-loader.tsx create mode 100644 app/components/toc-section.tsx create mode 100644 app/fonts.ts create mode 100644 app/lib/github-contributions.ts create mode 100644 app/lib/weather.ts create mode 100644 app/music-for-life/[id]/loading.tsx create mode 100644 app/music-for-life/[id]/page.tsx create mode 100644 app/music-for-life/loading.tsx rename app/{playlists => music-for-life}/map/layout.tsx (100%) rename app/{playlists => music-for-life}/map/map-viewport-lock.tsx (100%) rename app/{playlists => music-for-life}/map/page.tsx (67%) rename app/{playlists => music-for-life}/opengraph-image.tsx (100%) rename app/{playlists => music-for-life}/page.tsx (83%) delete mode 100644 app/playlists/[id]/page.tsx diff --git a/.env.example b/.env.example index 6d4a16e..b81d8af 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,7 @@ SPOTIFY_CLIENT_SECRET= SPOTIFY_REFRESH_TOKEN= # --- Mapbox ------------------------------------------------------------------- -# Public token for `/playlists/map` (Mapbox GL JS). Create at mapbox.com → Access tokens. +# Public token for `/music-for-life/map` (Mapbox GL JS). Create at mapbox.com → Access tokens. NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= # Optional: override basemap style (default Standard monochrome). Example: # NEXT_PUBLIC_MAPBOX_MAP_STYLE=mapbox://styles/mapbox/outdoors-v12 diff --git a/AGENTS.md b/AGENTS.md index d19a2ba..783e49a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,41 @@ Run `git status` and `git diff --staged` before committing. --- +## Site patterns (for agents) + +Keep **[README.md](README.md)** in sync when you touch these areas. + +### Music for Life routes + +| Path | Notes | +| --- | --- | +| **`/music-for-life`** | Index — curated Spotify playlists (`MUSIC_FOR_LIFE_BASE` in [`lib/spotify-playlists.ts`](lib/spotify-playlists.ts)) | +| **`/music-for-life/[id]`** | Single playlist — title + **Tracks** TOC only (not per-song) | +| **`/music-for-life/map`** | Fullscreen Mapbox GL; **no PageToc**, compact footer via `html.playlist-map-view` | + +Legacy **`/playlists`** and **`/playlists/*`** → **301 permanent redirect** to `/music-for-life/*` in [`next.config.ts`](next.config.ts). Use `MUSIC_FOR_LIFE_BASE` for all new internal links — never hardcode `/playlists`. + +Config: [`app/lib/spotify-playlists.ts`](app/lib/spotify-playlists.ts) (`CURATED_SPOTIFY_PLAYLISTS`, server fetch). Shared types/helpers: [`lib/spotify-playlists.ts`](lib/spotify-playlists.ts). + +### Page table of contents (`PageToc`) + +- **Components:** [`app/components/page-toc.tsx`](app/components/page-toc.tsx), [`app/components/toc-section.tsx`](app/components/toc-section.tsx) +- **Mount:** [`app/components/subpage-enter.tsx`](app/components/subpage-enter.tsx) — portaled to `document.body`, `position: fixed` (does not affect layout flow) +- **Discovery order:** explicit `TocSection` (`data-toc-item`) → `.mag-label` → `.mag-card h1/h2` +- **Shown when:** ≥2 entries, viewport gutter ≥28px right of content column, not on excluded paths (`/music-for-life/map`) +- **Home `/`:** `TocSection` labels — Kai Thomas Chen, Greeting, Contact; `.mag-label` — Listening, Location, Projects. Identity row uses **`md:flex-row-reverse`** + mobile `order-*` so TOC DOM order matches reading order **without** CSS Grid row-height gaps under the social column. +- **Playlist pages:** index = Music for Life + each playlist name (`level={1}`); detail = playlist name + **Tracks** — never one TOC entry per song. + +### Performance / loading (recent) + +- **Fonts:** Nunito via [`app/fonts.ts`](app/fonts.ts) (`next/font/google`); no `@fontsource` in client bundle +- **Weather:** Berkeley weather SSR on home via [`app/lib/weather.ts`](app/lib/weather.ts); `WeatherCard` accepts `initialWeather` +- **Mapbox:** dynamic import in [`app/components/playlist-map-loader.tsx`](app/components/playlist-map-loader.tsx) +- **GitHub heatmap:** prefetch on `/projects` via [`app/lib/github-contributions.ts`](app/lib/github-contributions.ts) +- **Route loading UI:** `app/music-for-life/loading.tsx`, `app/music-for-life/[id]/loading.tsx` + +--- + ## Where to read more | Topic | Document | diff --git a/CLAUDE.md b/CLAUDE.md index d1305cd..0ca969e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,9 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C | **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s** (`cache: "no-store"`) | | **GitHub** | `GET /api/github/contributions` — GraphQL calendar; pinned repos in `app/lib/github-pinned.ts` | | **Weather** | `GET /api/weather` — Open-Meteo (Berkeley); `berkeley-time.tsx` | -| **Home UI** | Identity + social links; **Listening** / **Location** cards; **Projects** outer card with nested Course + Personal (`projects-split.tsx`) | +| **Home UI** | Identity + social links; **Listening** / **Location** cards; **Projects** outer card with nested Course + Personal (`projects-split.tsx`); **PageToc** (Kai Thomas Chen, Greeting, Contact, Listening, Location, Projects) | +| **Music for Life** | `/music-for-life` — curated Spotify playlists ([`MUSIC_FOR_LIFE_BASE`](lib/spotify-playlists.ts)); `[id]` detail; `/map` Mapbox viewer; old `/playlists` → 301 redirect | +| **Page TOC** | [`page-toc.tsx`](app/components/page-toc.tsx) + [`toc-section.tsx`](app/components/toc-section.tsx) — fixed right rail on `/`, `/about`, `/projects`, `/misc`, `/music-for-life` (not map); auto `.mag-label` + explicit `TocSection` | | **Course Projects** | `app/lib/course-projects.ts` — Oxford DUL (`/projects/oxford-dul-2025`) + Notion notes | | **Oxford portfolio** | `app/lib/oxford-dul-projects.ts` + `app/projects/oxford-dul-2025/` + `app/components/oxford-dul/` | | **Nav** | Bold labels; active link = accent + pill underline (`.nav-link` in `globals.css`) | @@ -49,6 +51,10 @@ npm run lint && npm run typecheck && npm run test && npm run build - [`app/components/oxford-dul/`](app/components/oxford-dul/) — portfolio UI components - [`app/components/pinned-project-link.tsx`](app/components/pinned-project-link.tsx) — GitHub pinned repo cards/rows - [`app/components/hover-link-hint.tsx`](app/components/hover-link-hint.tsx) — ↗ hover hints +- [`app/components/page-toc.tsx`](app/components/page-toc.tsx) — right-side page TOC rail + panel +- [`app/components/toc-section.tsx`](app/components/toc-section.tsx) — explicit TOC scroll targets +- [`lib/spotify-playlists.ts`](lib/spotify-playlists.ts) — `MUSIC_FOR_LIFE_BASE`, playlist types, map URL helper +- [`app/music-for-life/`](app/music-for-life/) — Music for Life index, `[id]`, `/map` - [`public/portfolio/oxford-dul-2025/`](public/portfolio/oxford-dul-2025/) — exported training PNGs - [`lib/now-playing.ts`](lib/now-playing.ts) — Spotify types shared by API + hook - [`app/globals.css`](app/globals.css) — `.mag-card`, `.mag-card-inset`, `.nav-link`, social link hovers diff --git a/README.md b/README.md index 104af8f..1b8b934 100644 --- a/README.md +++ b/README.md @@ -104,10 +104,10 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende | Route / link | Role | | ------------ | -------------------------------------------------------------------- | -| `/` | First impression — identity, social links, Listening, Location, Projects | +| `/` | First impression — identity, social links, Listening, Location, Projects; **PageToc** (Kai Thomas Chen → Greeting → Contact → cards) | | `/about` | Deeper self — education, Focus, experience, volunteering | | `/projects` | Course Projects, Personal Projects, GitHub Activity | -| `/playlists` | **Music for Life** — curated public Spotify playlists; index cards link to `/playlists/[id]` for tracks; place journals link cities to `/playlists/map` | +| `/music-for-life` | **Music for Life** — curated public Spotify playlists; index cards link to `/music-for-life/[id]` for tracks; place journals link cities to `/music-for-life/map` | | `/misc` | Watching, Remembrance, Things I Love | @@ -124,6 +124,8 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende **Hover tips:** home social icons and card help bubbles use `[HoverTip](app/components/hover-tip.tsx)` — **70ms show delay**, fade + slide (`globals.css` `.hover-tip-bubble`, 220ms ease). Place-name links on playlist pages use row-style hover (background first, accent text via `group-hover`, 300ms). +**Page TOC:** fixed right rail on long pages (`[page-toc.tsx](app/components/page-toc.tsx)`) — hover/focus expands a jump list. Sections from `[TocSection](app/components/toc-section.tsx)` (`data-toc-item`) and `.mag-label` headings. Hidden on `/music-for-life/map` and when the viewport gutter is too narrow. Legacy **`/playlists`** URLs **301** redirect to **`/music-for-life`** (`next.config.ts`). + --- @@ -183,7 +185,7 @@ npm run lint && npm run typecheck && npm run test && npm run build | ---------- | ------------------------------------------------------------------------------------------------------ | | Framework | Next.js **16** (App Router, **Turbopack**), React **19**, TypeScript **5** | | Styling | Tailwind CSS **4**, magazine cards in `[app/globals.css](app/globals.css)` (`.mag-card`, `.mag-label`) | -| Fonts | **Geist Sans / Mono** on `body`; **Nunito** for nav + cards; JetBrains Mono where needed | +| Fonts | **Geist Sans / Mono** on `body` (`geist/font`); **Nunito** via `next/font/google` (`app/fonts.ts`) for nav + cards; star counts use Geist Mono | | Data | Optional **Supabase** (`listening_history` / `listening_stats`) for Spotify route | | Monitoring | Optional **Sentry**; **Vercel Analytics** + **Speed Insights** | | Testing | **Vitest** — see `[lib/*.test.ts](lib/)` | @@ -205,7 +207,7 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal project cards, GitHub heatmap │ │ └── oxford-dul-2025/ # Oxford summer coursework portfolio (landing + [slug]) │ ├── misc/ # Watching, Remembrance, Things I Love + OG -│ ├── playlists/ # Music for Life — index, [id] detail, /map viewer + OG +│ ├── music-for-life/ # Music for Life — index, [id] detail, /map viewer + OG │ │ ├── page.tsx # Curated playlist cards (Spotify metadata) │ │ ├── [id]/page.tsx # Full track list per playlist │ │ └── map/ # Fullscreen Mapbox GL (place journals) @@ -217,7 +219,8 @@ kaichen.dev/ │ ├── components/ │ │ ├── oxford-dul/ # Portfolio cards, project sections, projects-split │ │ ├── nav.tsx # Bold labels; pill-shaped active underline -│ │ └── … # listening-card, hover-tip, playlist-description, playlist-map, … +│ │ └── … # listening-card, hover-tip, page-toc, toc-section, playlist-description, playlist-map, … +│ ├── fonts.ts # Nunito via next/font/google │ ├── hooks/use-now-playing.ts │ ├── lib/ │ │ ├── oxford-dul-projects.ts # Oxford portfolio metadata + 6 projects @@ -248,14 +251,14 @@ kaichen.dev/ | Route | What it does | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/` | Identity; social links; **Listening** + **Location** cards; **Projects** outer card with nested **Course Projects** and **Personal Projects** (GitHub pins, list rows with hover ↗) | +| `/` | Identity; social links; **Listening** + **Location** cards; **Projects** outer card; **PageToc** (Kai Thomas Chen, Greeting, Contact, Listening, Location, Projects) | | `/about` | Personality, education (incl. Oxford → portfolio link), Focus, experience, volunteering | | `/projects` | **Course Projects** cards; **Personal Projects** grid; GitHub contribution calendar | | `/projects/oxford-dul-2025` | Oxford **Deep Unsupervised Learning** landing — overview, learning path, 6 project cards, academic record | | `/projects/oxford-dul-2025/{slug}` | Per-project detail — Problem, Dataset, Approach, Training, My Work, Evaluation, Results, Takeaway | -| `/playlists` | **Music for Life** — curated playlist IDs in `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` (display order = array order). Descriptions live from Spotify; place journals split **period** / **places** on two lines; city names link to Mapbox via coords in config. Index → `/playlists/[id]` for tracks. `revalidate = 3600`. Home Listening chip: **music for life** | -| `/playlists/[id]` | Single playlist — cover, Spotify description, two-column track list, back chip to `/playlists` | -| `/playlists/map` | Fullscreen Mapbox GL viewer for a place (`?lng=&lat=&zoom=&label=`); compact footer, no page scroll | +| `/music-for-life` | **Music for Life** — curated playlist IDs in `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` (display order = array order). Descriptions live from Spotify; place journals split **period** / **places** on two lines; city names link to Mapbox via coords in config. Index → `/music-for-life/[id]` for tracks. `revalidate = 3600`. Home Listening chip: **music for life** | +| `/music-for-life/[id]` | Single playlist — cover, Spotify description, two-column track list, back chip to `/music-for-life`; PageToc = playlist title + **Tracks** (not per-song) | +| `/music-for-life/map` | Fullscreen Mapbox GL viewer for a place (`?lng=&lat=&zoom=&label=`); compact footer, no page scroll | | `/misc` | **Watching** (dated news links), **Remembrance**, **Things I Love** (nested category inset cards) | @@ -286,7 +289,7 @@ Copy `[.env.example](.env.example)` to `.env.local`. **Never commit secrets.** | `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (Spotify listening history) | | `SUPABASE_SERVICE_ROLE_KEY` | **Server-only** — `listening_history` / `listening_stats` | | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify app + refresh token (`user-read-currently-playing`, `user-read-recently-played`) | -| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | Mapbox GL JS on `/playlists/map` (public token from mapbox.com) | +| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | Mapbox GL JS on `/music-for-life/map` (public token from mapbox.com) | | `GITHUB_TOKEN` | GitHub API — contributions + pinned repos | | `GITHUB_LOGIN` | Optional username (default `kaiiiichen`) | | `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | Optional error reporting | @@ -305,7 +308,7 @@ vercel env pull .env.vercel.check # gitignored — do not commit | Service | Use | | ------------------- | ------------------------------------------------------ | | **Spotify Web API** | Now playing + recently played + public playlist metadata/tracks (`GET /playlists/{id}`, `/items`) | -| **Mapbox GL JS** | Place map viewer on `/playlists/map` (`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | +| **Mapbox GL JS** | Place map viewer on `/music-for-life/map` (`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | | **GitHub GraphQL** | Contribution calendar + pinned repos (stars, archived) | | **Open-Meteo** | Berkeley weather (no key) | | **Supabase** | Optional listening history persistence | @@ -451,10 +454,10 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | 路由 / 链接 | 作用 | | --------- | ----------------------------------------------- | -| `/` | 第一印象 —— 身份、社交链接、Listening、Location、Projects | +| `/` | 第一印象 —— 身份、社交链接、Listening、Location、Projects;**PageToc**(Kai Thomas Chen → Greeting → Contact → 卡片) | | `/about` | 更深的自我 —— 教育、Focus、经历、志愿 | | `/projects` | Course Projects、Personal Projects、GitHub Activity | -| `/playlists` | **Music for Life** — 精选公开 Spotify 歌单;索引卡片链到 `/playlists/[id]` 看曲目;地点日记的城市名链到 `/playlists/map` | +| `/music-for-life` | **Music for Life** — 精选公开 Spotify 歌单;索引卡片链到 `/music-for-life/[id]` 看曲目;地点日记的城市名链到 `/music-for-life/map` | | `/misc` | Watching、Remembrance、Things I Love | @@ -473,6 +476,8 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. **Hover 气泡:** 首页社交图标与卡片帮助提示使用 `[HoverTip](app/components/hover-tip.tsx)` — **70ms 延迟**后淡入 + 上滑(`globals.css` `.hover-tip-bubble`)。歌单页地名链接采用行级 hover(先背景、再 accent 文字,300ms)。 +**Page TOC:** 长页右侧固定目录(`[page-toc.tsx](app/components/page-toc.tsx)`),悬停/聚焦展开跳转列表;区块来自 `[TocSection](app/components/toc-section.tsx)` 与 `.mag-label`。`/music-for-life/map` 不显示。旧 **`/playlists`** 路径 **301** 重定向至 **`/music-for-life`**。 + --- @@ -556,7 +561,7 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal 项目卡片、GitHub 热力图 │ │ └── oxford-dul-2025/ # 牛津暑校作品集(落地页 + [slug] 详情) │ ├── misc/ # Watching、Remembrance、Things I Love + OG -│ ├── playlists/ # Music for Life — 索引、[id] 详情、/map 地图 + OG +│ ├── music-for-life/ # Music for Life — 索引、[id] 详情、/map 地图 + OG │ │ ├── page.tsx │ │ ├── [id]/page.tsx │ │ └── map/ @@ -568,7 +573,8 @@ kaichen.dev/ │ ├── components/ │ │ ├── oxford-dul/ # 作品集卡片、详情区块、projects-split │ │ ├── nav.tsx # 粗体标签;圆角 active 下划线 -│ │ └── … # listening-card、hover-tip、playlist-description、playlist-map 等 +│ │ └── … # listening-card、hover-tip、page-toc、toc-section、playlist-description、playlist-map 等 +│ ├── fonts.ts # Nunito(next/font/google) │ ├── hooks/use-now-playing.ts │ ├── lib/ │ │ ├── oxford-dul-projects.ts @@ -601,9 +607,9 @@ kaichen.dev/ | `/projects` | Course Projects 卡片;Personal Projects 网格;GitHub 贡献日历 | | `/projects/oxford-dul-2025` | 牛津 **Deep Unsupervised Learning** 落地页 | | `/projects/oxford-dul-2025/{slug}` | 单项目详情(Problem / Dataset / Training / Evaluation / Results 等) | -| `/playlists` | **Music for Life** — 配置在 `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`(数组顺序即展示顺序)。简介来自 Spotify;地点日记 **时间 / 地点** 分两行;城市名通过 `placeCoords` 链 Mapbox。索引 → `/playlists/[id]`。`revalidate = 3600`。首页 chip:**music for life** | -| `/playlists/[id]` | 单张歌单 — 封面、简介、双列曲目、返回 `/playlists` | -| `/playlists/map` | 全屏 Mapbox 地图(`?lng=&lat=&zoom=&label=`);紧凑页脚、禁止页面滚动 | +| `/music-for-life` | **Music for Life** — 配置在 `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`(数组顺序即展示顺序)。简介来自 Spotify;地点日记 **时间 / 地点** 分两行;城市名通过 `placeCoords` 链 Mapbox。索引 → `/music-for-life/[id]`。`revalidate = 3600`。首页 chip:**music for life** | +| `/music-for-life/[id]` | 单张歌单 — 封面、简介、双列曲目、返回 `/music-for-life`;PageToc = 歌单名 + **Tracks**(非逐曲) | +| `/music-for-life/map` | 全屏 Mapbox 地图(`?lng=&lat=&zoom=&label=`);紧凑页脚、禁止页面滚动 | | `/misc` | **Watching**、**Remembrance**、嵌套 **Things I Love** | @@ -634,7 +640,7 @@ kaichen.dev/ | `NEXT_PUBLIC_SUPABASE_URL` | Supabase 项目 URL(Spotify 听歌记录) | | `SUPABASE_SERVICE_ROLE_KEY` | **仅服务端** —— `listening_history` / `listening_stats` | | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify 应用 + refresh token(`user-read-currently-playing`、`user-read-recently-played`) | -| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | `/playlists/map` 的 Mapbox GL JS(mapbox.com 创建的 public token) | +| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | `/music-for-life/map` 的 Mapbox GL JS(mapbox.com 创建的 public token) | | `GITHUB_TOKEN` | GitHub API —— 贡献日历 + 置顶仓库 | | `GITHUB_LOGIN` | 可选用户名(默认 `kaiiiichen`) | | `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | 可选错误上报 | @@ -653,7 +659,7 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交 | 服务 | 用途 | | ------------------- | -------------------------- | | **Spotify Web API** | 正在播放 + 最近播放 + 公开歌单元数据/曲目(`GET /playlists/{id}`、`/items`) | -| **Mapbox GL JS** | `/playlists/map` 地点地图(`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | +| **Mapbox GL JS** | `/music-for-life/map` 地点地图(`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | | **GitHub GraphQL** | 贡献日历 + 置顶仓库(star、archived) | | **Open-Meteo** | 伯克利天气(无需 key) | | **Supabase** | 可选听歌记录持久化 | diff --git a/app/about/page.tsx b/app/about/page.tsx index 51145a7..a5cd521 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -20,7 +20,7 @@ export default function About() { {/* Header */}

About @@ -29,7 +29,7 @@ export default function About() { {/* Personality / side */}
-
+

I study Maths for my bachelor's degree and I'm always awed by the beauty of it (as the motivation to learn it through so much hard work)! I love the outline of analysis proofs and I'm especially obsessed with algebra structures.

I also learn some computer science and data science, and I find myself better at them than Maths lol. I'm drawn to the problem solving, ideas behind product design, and the empathy I feel when I'm building something for humans.

I really love tinkering stuff. I dream of building something that can help people / make people happy with a fantastic user experience! I also dream of building a tool that could change people's lives for the better.

@@ -80,26 +80,26 @@ export default function About() { className="py-4 md:py-3 border-b border-zinc-100 dark:border-zinc-800/60 last:border-0" > {years}

{institution}

{role}

{subtitle ? (

{subtitle} @@ -107,7 +107,7 @@ export default function About() { ) : null} {content || grade ? (

{[content, grade].filter(Boolean).join(" · ")} @@ -136,13 +136,13 @@ export default function About() { className="py-4 border-b border-zinc-100 dark:border-zinc-800/60 last:border-0" > {term}

{code} @@ -216,14 +216,14 @@ export default function About() { className="py-4 md:py-3 border-b border-zinc-100 dark:border-zinc-800/60 last:border-0" >

{org}

{orgMeta ? (

{orgMeta} @@ -240,14 +240,14 @@ export default function About() { } >

{roleItem.role}

{roleItem.detail ? (

{roleItem.detail} @@ -293,14 +293,14 @@ export default function About() { className="py-4 md:py-3 border-b border-zinc-100 dark:border-zinc-800/60 last:border-0" >

{org}

{orgMeta ? (

{orgMeta} @@ -317,14 +317,14 @@ export default function About() { } >

{roleItem.role}

{roleItem.detail ? (

{roleItem.detail} diff --git a/app/api/github/contributions/route.ts b/app/api/github/contributions/route.ts index 3ad8f0a..bc9028e 100644 --- a/app/api/github/contributions/route.ts +++ b/app/api/github/contributions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { GITHUB_PROFILE_LOGIN } from "@/app/lib/github-pinned"; +import { getGitHubContributions } from "@/app/lib/github-contributions"; /** Contribution calendar changes slowly — let the CDN serve it for 5 minutes. */ export const revalidate = 300; @@ -8,65 +8,10 @@ const cacheHeaders = { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", }; -const QUERY = ` - query($login: String!, $from: DateTime!, $to: DateTime!) { - user(login: $login) { - contributionsCollection(from: $from, to: $to) { - contributionCalendar { - totalContributions - weeks { - contributionDays { - date - contributionCount - } - } - } - } - } - } -`; - export async function GET() { - const token = process.env.GITHUB_TOKEN; - if (!token) { - return NextResponse.json({ error: "No token" }, { status: 500 }); + const data = await getGitHubContributions(); + if (!data) { + return NextResponse.json({ error: "No token or GitHub API error" }, { status: 500 }); } - - const now = new Date(); - const from = new Date(now); - from.setFullYear(now.getFullYear() - 1); - - const graphqlRes = await fetch("https://api.github.com/graphql", { - method: "POST", - next: { revalidate: 300 }, - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query: QUERY, - variables: { - login: GITHUB_PROFILE_LOGIN, - from: from.toISOString(), - to: now.toISOString(), - }, - }), - }); - - if (!graphqlRes.ok) { - return NextResponse.json({ error: "GitHub API error" }, { status: 502 }); - } - - const data = await graphqlRes.json(); - const calendar = - data?.data?.user?.contributionsCollection?.contributionCalendar; - const rawWeeks: { contributionDays: { date: string; contributionCount: number }[] }[] = - calendar?.weeks ?? []; - const totalContributions: number = calendar?.totalContributions ?? 0; - - const weeks = rawWeeks.map((w) => - w.contributionDays.map((d) => ({ date: d.date, count: d.contributionCount })) - ); - - return NextResponse.json({ weeks, totalContributions }, { headers: cacheHeaders }); + return NextResponse.json(data, { headers: cacheHeaders }); } diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts index bf50f4e..8b4f14d 100644 --- a/app/api/weather/route.ts +++ b/app/api/weather/route.ts @@ -1,23 +1,14 @@ import { NextResponse } from "next/server"; -import { - parseOpenMeteoForecast, - type OpenMeteoForecastPayload, -} from "@/lib/weather-open-meteo"; +import { getBerkeleyWeather } from "@/app/lib/weather"; -export async function GET() { - const url = - "https://api.open-meteo.com/v1/forecast" + - "?latitude=37.8716&longitude=-122.2728" + - "¤t=temperature_2m,weathercode,apparent_temperature,relative_humidity_2m" + - "&hourly=precipitation_probability" + - "&temperature_unit=celsius" + - "&timezone=America%2FLos_Angeles" + - "&forecast_days=1"; - - const res = await fetch(url, { next: { revalidate: 600 } }); - if (!res.ok) return NextResponse.json({ error: "weather fetch failed" }, { status: 502 }); +const WEATHER_CACHE_HEADERS = { + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=120", +}; - const data = (await res.json()) as OpenMeteoForecastPayload; - const payload = parseOpenMeteoForecast(data); - return NextResponse.json(payload); +export async function GET() { + const payload = await getBerkeleyWeather(); + if (!payload) { + return NextResponse.json({ error: "weather fetch failed" }, { status: 502 }); + } + return NextResponse.json(payload, { headers: WEATHER_CACHE_HEADERS }); } diff --git a/app/components/GitHubActivity.tsx b/app/components/GitHubActivity.tsx index a737f8e..8e3bc66 100644 --- a/app/components/GitHubActivity.tsx +++ b/app/components/GitHubActivity.tsx @@ -2,8 +2,12 @@ import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; +import type { ContributionDay } from "@/app/lib/github-contributions"; -type Day = { date: string; count: number }; +type GitHubActivityProps = { + weeks: ContributionDay[][]; + totalContributions: number; +}; const MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const CELL = 16; @@ -18,14 +22,13 @@ function getColor(count: number): string { return "var(--contribution-l4)"; } -function getMonthLabels(weeks: Day[][]): { label: string; col: number }[] { +function getMonthLabels(weeks: ContributionDay[][]): { label: string; col: number }[] { const labels: { label: string; col: number }[] = []; let lastMonth = -1; weeks.forEach((week, i) => { if (!week[0]) return; const month = new Date(week[0].date).getUTCMonth(); if (month !== lastMonth) { - // skip first column to avoid clipping if (i > 0) labels.push({ label: MONTHS[month], col: i }); lastMonth = month; } @@ -38,9 +41,7 @@ function formatDate(dateStr: string): string { return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); } -export default function GitHubActivity() { - const [weeks, setWeeks] = useState([]); - const [total, setTotal] = useState(0); +export default function GitHubActivity({ weeks, totalContributions }: GitHubActivityProps) { const [hoveredInfo, setHoveredInfo] = useState<{ date: string; count: number; rect: DOMRect } | null>(null); useEffect(() => { @@ -53,23 +54,12 @@ export default function GitHubActivity() { }; }, []); - useEffect(() => { - fetch("/api/github/contributions") - .then((r) => r.json()) - .then((data: { weeks?: Day[][]; totalContributions?: number }) => { - if (data.weeks) setWeeks(data.weeks); - if (data.totalContributions) setTotal(data.totalContributions); - }) - .catch(() => {}); - }, []); - if (weeks.length === 0) return

; const monthLabels = getMonthLabels(weeks); return (
- {/* Month labels */}
{monthLabels.map(({ label, col }) => ( - {/* Grid */}
{weeks.map((week, wi) => (
@@ -117,7 +106,6 @@ export default function GitHubActivity() { ))}
- {/* Portal tooltip — renders into document.body to escape any overflow:hidden ancestor */} {hoveredInfo && typeof document !== "undefined" && createPortal(
0 && ( + {totalContributions > 0 && (

- {total.toLocaleString()} contributions in the last year + {totalContributions.toLocaleString()} contributions in the last year

)}
diff --git a/app/components/hover-link-hint.tsx b/app/components/hover-link-hint.tsx index 15e12f6..20f8c8c 100644 --- a/app/components/hover-link-hint.tsx +++ b/app/components/hover-link-hint.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { MUSIC_FOR_LIFE_BASE } from "@/lib/spotify-playlists"; /** Resolve a short destination label from a URL or site path (for hover hints). */ export function linkDestinationLabel(href: string): string { @@ -8,9 +9,9 @@ export function linkDestinationLabel(href: string): string { if (href.startsWith("/projects")) return "Projects"; if (href.startsWith("/about")) return "About"; if (href.startsWith("/misc")) return "Misc"; - if (href.startsWith("/playlists/map")) return "Map"; - if (/^\/playlists\/[^/]+/.test(href)) return "Tracks"; - if (href.startsWith("/playlists")) return "Music for Life"; + if (href.startsWith(`${MUSIC_FOR_LIFE_BASE}/map`)) return "Map"; + if (new RegExp(`^${MUSIC_FOR_LIFE_BASE}/[^/]+`).test(href)) return "Tracks"; + if (href.startsWith(MUSIC_FOR_LIFE_BASE)) return "Music for Life"; return "Site"; } @@ -46,19 +47,22 @@ export function destinationHint(href: string, override?: string): string { return `${override ?? linkDestinationLabel(href)} ↗`; } -/** Leading ↗ — fades in on parent `.group` hover (matches personal project cards). */ +const HINT_ACCENT = "text-[#C4894F] dark:text-[#D9A870]"; +const HINT_MOTION = "transition-all duration-150"; + +/** Leading ↗ — fades + slides in from the left on parent `.group` hover. */ export function HoverLinkArrow({ className = "" }: { className?: string }) { return ( ); } -/** Trailing label — plain text, appears on parent `.group` hover (e.g. `GitHub ↗`). */ +/** Trailing hint text — fades + slides in from the right (mirror of HoverLinkArrow). */ export function HoverLinkHint({ children, className = "", @@ -68,15 +72,15 @@ export function HoverLinkHint({ }) { return ( {children} ); } -/** Trailing hint derived from link target — `{label} ↗`. */ +/** Trailing hint — `{label} ↗` with label + arrow sliding in from the right. */ export function HoverLinkDestinationHint({ href, label, @@ -86,5 +90,24 @@ export function HoverLinkDestinationHint({ label?: string; className?: string; }) { - return {destinationHint(href, label)}; + const text = label ?? linkDestinationLabel(href); + + return ( + + + {text} + + + ↗ + + + ); } diff --git a/app/components/hover-tip.tsx b/app/components/hover-tip.tsx index 8f24911..bfe59ed 100644 --- a/app/components/hover-tip.tsx +++ b/app/components/hover-tip.tsx @@ -68,7 +68,7 @@ function portalFixedStyle( const base: CSSProperties = { position: "fixed", zIndex: TIP_Z_INDEX, - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 400, }; @@ -264,7 +264,7 @@ export default function HoverTip({ > {tip} @@ -293,7 +293,7 @@ export default function HoverTip({ className={`${TIP_BASE_CLASS} hover-tip-bubble ${ placement === "bottom" ? "hover-tip-bubble--bottom" : "" } ${interactive ? "group-hover/hover-tip:pointer-events-auto group-focus-within/hover-tip:pointer-events-auto" : ""}`} - style={{ fontFamily: "'Nunito'", fontWeight: 400 }} + style={{ fontFamily: "var(--font-nunito)", fontWeight: 400 }} > {tip} diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx index ef594f9..354c4a6 100644 --- a/app/components/listening-card.tsx +++ b/app/components/listening-card.tsx @@ -5,6 +5,7 @@ import type { CSSProperties } from "react"; import ListeningLastMonthTop from "./listening-last-month-top"; import ListeningTrackRow from "./listening-track-row"; import MagChip from "./mag-chip"; +import { MUSIC_FOR_LIFE_BASE } from "@/lib/spotify-playlists"; // Spotify's audio-features API was retired (403 for this app), so the live dot // uses fixed timings instead of per-track BPM/intensity. @@ -24,12 +25,12 @@ function ListeningEmptyVisual() {
No signal - + Nothing on the speakers yet—Spotify picks this up automatically when you listen.
@@ -83,7 +84,7 @@ export default function ListeningCard() { aria-hidden />
- + music for life
diff --git a/app/components/listening-last-month-top.tsx b/app/components/listening-last-month-top.tsx index b360a7f..1570c0e 100644 --- a/app/components/listening-last-month-top.tsx +++ b/app/components/listening-last-month-top.tsx @@ -6,7 +6,8 @@ import HoverTip from "./hover-tip"; import ListeningTrackRow from "./listening-track-row"; import MagChip from "./mag-chip"; -export default function ListeningLastMonthTop() { +/** Fetches when the portaled tooltip mounts (on first open) — not on page load. */ +function LastMonthTopTipContent() { const [tracks, setTracks] = useState(null); useEffect(() => { @@ -24,8 +25,47 @@ export default function ListeningLastMonthTop() { }; }, []); - if (tracks === null) return null; + if (tracks === null) { + return ( + + Loading… + + ); + } + + return ( + + + last month · top 5 + + {tracks.length === 0 ? ( + + No plays recorded last month. + + ) : ( + tracks.map((track) => ( + + )) + )} + + ); +} +export default function ListeningLastMonthTop() { return ( - - last month · top 5 - - {tracks.length === 0 ? ( - - No plays recorded last month. - - ) : ( - tracks.map((track) => ( - - )) - )} -
- } + tip={} >

Kai T. Chen @@ -59,7 +59,7 @@ export default function Nav() { {label} @@ -102,7 +102,7 @@ export default function Nav() { isOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1" }`; const linkStyle = { - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 600, transitionDelay: isOpen ? `${60 + i * 40}ms` : "0ms", }; diff --git a/app/components/oxford-dul/course-project-link.tsx b/app/components/oxford-dul/course-project-link.tsx index d035d16..3357fdf 100644 --- a/app/components/oxford-dul/course-project-link.tsx +++ b/app/components/oxford-dul/course-project-link.tsx @@ -11,7 +11,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro const { href, institution, title, grade, summary, tags, external, hintLabel } = entry; const nameStyle = { - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 600, fontSize: variant === "card" ? 20 : 18, fontStyle: "italic" as const, @@ -32,7 +32,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro

{grade ? ( {grade} @@ -46,7 +46,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro {tags.map((tag) => ( {tag} @@ -66,7 +66,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro {institution ? (

{institution} @@ -74,7 +74,7 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro ) : null}

{summary} @@ -113,14 +113,14 @@ export default function CourseProjectLink({ entry, variant = "list" }: CoursePro

{institution ? (

{institution}

) : null}

{summary} diff --git a/app/components/oxford-dul/disclaimer.tsx b/app/components/oxford-dul/disclaimer.tsx index 88db9c2..880716f 100644 --- a/app/components/oxford-dul/disclaimer.tsx +++ b/app/components/oxford-dul/disclaimer.tsx @@ -7,7 +7,7 @@ type OxfordDulDisclaimerProps = { export default function OxfordDulDisclaimer({ className = "" }: OxfordDulDisclaimerProps) { return (

{OXFORD_DUL_DISCLAIMER} diff --git a/app/components/oxford-dul/image-gallery.tsx b/app/components/oxford-dul/image-gallery.tsx index 30599e1..a28cc6e 100644 --- a/app/components/oxford-dul/image-gallery.tsx +++ b/app/components/oxford-dul/image-gallery.tsx @@ -14,7 +14,7 @@ export default function OxfordDulImageGallery({ return (

{emptyMessage} @@ -39,7 +39,7 @@ export default function OxfordDulImageGallery({

{img.caption ? (
{img.caption} diff --git a/app/components/oxford-dul/learning-path.tsx b/app/components/oxford-dul/learning-path.tsx index 7358b7f..d5d8339 100644 --- a/app/components/oxford-dul/learning-path.tsx +++ b/app/components/oxford-dul/learning-path.tsx @@ -13,13 +13,13 @@ export default function OxfordDulLearningPath() { className="border-l-2 border-[#C4894F]/40 dark:border-[#D9A870]/40 pl-4 md:pl-5" >

{title}

{topics} @@ -28,7 +28,7 @@ export default function OxfordDulLearningPath() { {projects.map((p) => (

  • {p.title} diff --git a/app/components/oxford-dul/project-card.tsx b/app/components/oxford-dul/project-card.tsx index 2bc546d..1127156 100644 --- a/app/components/oxford-dul/project-card.tsx +++ b/app/components/oxford-dul/project-card.tsx @@ -22,7 +22,7 @@ export default function OxfordDulProjectCard({ project }: OxfordDulProjectCardPr {isPlaceholder ? (
    Image pending @@ -43,14 +43,14 @@ export default function OxfordDulProjectCard({ project }: OxfordDulProjectCardPr

    {project.title}

    {project.oneLiner} @@ -60,7 +60,7 @@ export default function OxfordDulProjectCard({ project }: OxfordDulProjectCardPr {project.methods.map((tag) => ( {tag} diff --git a/app/components/oxford-dul/project-sections.tsx b/app/components/oxford-dul/project-sections.tsx index aba4855..3af2bcb 100644 --- a/app/components/oxford-dul/project-sections.tsx +++ b/app/components/oxford-dul/project-sections.tsx @@ -15,7 +15,7 @@ function Section({ label, children }: { label: string; children: React.ReactNode } const bodyStyle = { - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 400, fontSize: 16, lineHeight: 1.85, diff --git a/app/components/oxford-dul/projects-split.tsx b/app/components/oxford-dul/projects-split.tsx index 823b784..03c6b1d 100644 --- a/app/components/oxford-dul/projects-split.tsx +++ b/app/components/oxford-dul/projects-split.tsx @@ -45,7 +45,7 @@ export default function ProjectsSplit({ {personalProjects.length === 0 ? (

    {personalEmptyMessage} diff --git a/app/components/page-toc.tsx b/app/components/page-toc.tsx new file mode 100644 index 0000000..a654e31 --- /dev/null +++ b/app/components/page-toc.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { createPortal } from "react-dom"; +import { usePathname } from "next/navigation"; +import { tocId } from "./toc-section"; + +function useIsClient() { + return useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); +} + +type TocEntry = { + id: string; + label: string; + level: number; +}; + +const EXCLUDED_PATHS = new Set(["/music-for-life/map"]); +const SCROLL_OFFSET = 112; +/** Minimum clear pixels between page content and viewport right edge. */ +const TOC_MIN_RIGHT_GUTTER = 28; + +function findPageContentColumn(): HTMLElement | null { + return document.querySelector('main [class*="max-w-"]'); +} + +function hasRoomForToc(): boolean { + const content = findPageContentColumn(); + if (!content) return false; + const gutter = window.innerWidth - content.getBoundingClientRect().right; + return gutter >= TOC_MIN_RIGHT_GUTTER; +} + +function getLabelText(el: HTMLElement): string { + return el.textContent?.trim().replace(/\s+/g, " ") ?? ""; +} + +function resolveScrollTarget(labelEl: HTMLElement): HTMLElement | null { + const inset = labelEl.closest(".mag-card-inset"); + if (inset) return inset; + + const card = labelEl.closest(".mag-card"); + if (card) return card; + + const section = labelEl.closest("section"); + if (section) return section; + + const fadeUp = labelEl.closest(".fade-up"); + if (fadeUp) return fadeUp; + + return labelEl.parentElement; +} + +function ensureScrollTarget(el: HTMLElement, label: string): string { + if (el.id) { + el.style.scrollMarginTop = "5rem"; + return el.id; + } + + let candidate = tocId(label); + let suffix = 2; + while (document.getElementById(candidate) && document.getElementById(candidate) !== el) { + candidate = `${tocId(label)}-${suffix++}`; + } + + el.id = candidate; + el.style.scrollMarginTop = "5rem"; + return candidate; +} + +function isInsideExplicitToc(el: HTMLElement): boolean { + return Boolean(el.closest("[data-toc-item]")); +} + +function compareDocumentOrder(a: HTMLElement, b: HTMLElement): number { + if (a === b) return 0; + const position = a.compareDocumentPosition(b); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1; + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1; + return 0; +} + +function scanTocItems(): TocEntry[] { + const entries: { el: HTMLElement; label: string; level: number }[] = []; + const claimed = new Set(); + + const claim = (el: HTMLElement, label: string, level: number) => { + if (!label || claimed.has(el)) return; + claimed.add(el); + entries.push({ el, label, level }); + }; + + document.querySelectorAll("[data-toc-item]").forEach((el) => { + const label = el.getAttribute("data-toc-label") ?? ""; + const level = Number(el.getAttribute("data-toc-level") ?? 0); + if (label) claim(el, label, level); + }); + + document.querySelectorAll(".mag-label").forEach((labelEl) => { + const target = resolveScrollTarget(labelEl); + if (!target || isInsideExplicitToc(target)) return; + + const label = getLabelText(labelEl); + const level = labelEl.closest(".mag-card-inset") ? 1 : 0; + claim(target, label, level); + }); + + document.querySelectorAll(".mag-card h2, .mag-card h1").forEach((heading) => { + const target = heading.closest(".fade-up") ?? heading.closest(".mag-card"); + if (!target || isInsideExplicitToc(target) || claimed.has(target)) return; + + const label = getLabelText(heading); + claim(target, label, 0); + }); + + return entries + .sort((a, b) => compareDocumentOrder(a.el, b.el)) + .map(({ el, label, level }) => ({ + id: ensureScrollTarget(el, label), + label, + level, + })); +} + +export default function PageToc() { + const pathname = usePathname(); + const isClient = useIsClient(); + const [items, setItems] = useState([]); + const [activeId, setActiveId] = useState(null); + const [expanded, setExpanded] = useState(false); + const [hasRoom, setHasRoom] = useState(false); + + useEffect(() => { + const refresh = () => { + const next = scanTocItems(); + setItems(next); + setActiveId((prev) => { + if (prev && next.some((item) => item.id === prev)) return prev; + return next[0]?.id ?? null; + }); + }; + + refresh(); + const timer = window.setTimeout(refresh, 150); + return () => window.clearTimeout(timer); + }, [pathname]); + + useEffect(() => { + const updateRoom = () => { + setHasRoom(hasRoomForToc()); + }; + + updateRoom(); + const timer = window.setTimeout(updateRoom, 150); + window.addEventListener("resize", updateRoom); + + const content = findPageContentColumn(); + const observer = content ? new ResizeObserver(updateRoom) : null; + if (content) observer?.observe(content); + + return () => { + window.clearTimeout(timer); + window.removeEventListener("resize", updateRoom); + observer?.disconnect(); + }; + }, [pathname]); + + useEffect(() => { + if (items.length === 0) return; + + const onScroll = () => { + let current: string | null = items[0].id; + for (const { id } of items) { + const el = document.getElementById(id); + if (el && el.getBoundingClientRect().top <= SCROLL_OFFSET) { + current = id; + } + } + setActiveId(current); + }; + + const frame = requestAnimationFrame(onScroll); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + return () => { + cancelAnimationFrame(frame); + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + }; + }, [items]); + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); + }; + + if (EXCLUDED_PATHS.has(pathname) || items.length < 2 || !isClient || !hasRoom) { + return null; + } + + return createPortal( +

    , + document.body, + ); +} diff --git a/app/components/pinned-project-link.tsx b/app/components/pinned-project-link.tsx index 23a6bf9..bf2a932 100644 --- a/app/components/pinned-project-link.tsx +++ b/app/components/pinned-project-link.tsx @@ -18,7 +18,7 @@ export default function PinnedProjectLink({ const nameClass = "text-zinc-800 dark:text-zinc-200 group-hover:text-[#C4894F] dark:group-hover:text-[#D9A870] transition-colors duration-150"; const nameStyle = { - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 600, fontSize: variant === "card" ? 20 : 18, fontStyle: "italic" as const, @@ -43,7 +43,7 @@ export default function PinnedProjectLink({ {desc ? (

    {desc} @@ -55,7 +55,7 @@ export default function PinnedProjectLink({ {stack.map((tag) => ( {tag} @@ -88,7 +88,7 @@ export default function PinnedProjectLink({

    {desc ? (

    {desc} @@ -99,7 +99,7 @@ export default function PinnedProjectLink({ {stack.map((tag) => ( {tag} diff --git a/app/components/playlist-description.tsx b/app/components/playlist-description.tsx index 610d47e..c345329 100644 --- a/app/components/playlist-description.tsx +++ b/app/components/playlist-description.tsx @@ -30,7 +30,7 @@ const CREDIT_LINK_TEXT = "transition-colors duration-300 ease-in-out"; const textStyle = { - fontFamily: "'Nunito'", + fontFamily: "var(--font-nunito)", fontWeight: 400, fontSize: 13, lineHeight: 1.6, diff --git a/app/components/playlist-map-loader.tsx b/app/components/playlist-map-loader.tsx new file mode 100644 index 0000000..5c26d21 --- /dev/null +++ b/app/components/playlist-map-loader.tsx @@ -0,0 +1,27 @@ +"use client"; + +import dynamic from "next/dynamic"; +import type { ComponentProps } from "react"; +import type PlaylistMapComponent from "./playlist-map"; + +type PlaylistMapProps = ComponentProps; + +function PlaylistMapSkeleton({ fill }: { fill?: boolean }) { + return ( +

    + ); +} + +const PlaylistMapLazy = dynamic(() => import("./playlist-map"), { + ssr: false, + loading: () => , +}); + +export default function PlaylistMapLoader(props: PlaylistMapProps) { + return ; +} diff --git a/app/components/playlist-map.tsx b/app/components/playlist-map.tsx index 62ea8e8..6d15011 100644 --- a/app/components/playlist-map.tsx +++ b/app/components/playlist-map.tsx @@ -11,7 +11,7 @@ type PlaylistMapProps = { lat: number; zoom: number; label: string; - /** Fill the parent — used on fullscreen `/playlists/map`. */ + /** Fill the parent — used on fullscreen `/music-for-life/map`. */ fill?: boolean; }; @@ -77,7 +77,7 @@ export default function PlaylistMap({ lng, lat, zoom, label, fill = false }: Pla }`} >

    Set NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN in{" "} diff --git a/app/components/project-stars.tsx b/app/components/project-stars.tsx index 2bc5782..f7196b0 100644 --- a/app/components/project-stars.tsx +++ b/app/components/project-stars.tsx @@ -16,13 +16,13 @@ export default function ProjectStars({ stars, archived }: ProjectStarsProps) { return ( <> {archived && ( - + archived )} - {stars} + {stars} ); diff --git a/app/components/site-footer.tsx b/app/components/site-footer.tsx index ddbcc5c..4f0bfeb 100644 --- a/app/components/site-footer.tsx +++ b/app/components/site-footer.tsx @@ -1,26 +1,11 @@ -"use client"; - import Link from "next/link"; -import { usePathname } from "next/navigation"; export default function SiteFooter() { - const pathname = usePathname(); - const isMapPage = pathname.startsWith("/playlists/map"); - return ( -