diff --git a/AGENTS.md b/AGENTS.md index c616d05..d19a2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Instructions for **AI coding agents** (Cursor, Claude Code, Codex, Copilot, etc.) and anyone running **automated** edits against this repository. -**Human-oriented** contributor docs: [CONTRIBUTING.md](CONTRIBUTING.md). **Full project reference:** [README.md](README.md) (architecture, APIs, environment variables, CI, MDX, deployment). +**Human-oriented** contributor docs: [CONTRIBUTING.md](CONTRIBUTING.md). **Full project reference:** [README.md](README.md) (architecture, APIs, environment variables, CI, deployment). --- @@ -98,7 +98,7 @@ Run `git status` and `git diff --staged` before committing. | Topic | Document | | --- | --- | -| APIs, env, CI, MDX, fork checklist | [README.md](README.md) | +| APIs, env, CI, fork checklist | [README.md](README.md) | | Human contribution process | [CONTRIBUTING.md](CONTRIBUTING.md) | | Short Claude Code stack summary | [CLAUDE.md](CLAUDE.md) | | Security reporting | [SECURITY.md](SECURITY.md) | diff --git a/CLAUDE.md b/CLAUDE.md index 5d43c85..2aa3d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,11 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C | **Now playing** | `GET /api/spotify/now-playing` — Spotify Web API; optional Supabase writes (`listening_*`) with service role | | **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s** (`cache: "no-store"`); API `Cache-Control: public, s-maxage=10, stale-while-revalidate=5` | | **GitHub** | `GET /api/github/contributions` — GraphQL calendar; pinned repos (incl. `stargazerCount` / `isArchived`) + static fallback in `app/lib/github-pinned.ts` | -| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley, CA); current temp, **feels-like**, **humidity**, condition, rain chance; `next.revalidate = 600`; local clock in card via `app/components/berkeley-time.tsx` (`America/Los_Angeles`) | +| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley, CA); current temp, **feels-like**, **humidity**, **rain chance**; `next.revalidate = 600`; local clock in card via `app/components/berkeley-time.tsx` (`America/Los_Angeles`) | | **Home UI** | Identity + **social links** (brand-colored hovers), **Listening** / **Location** cards — `app/components/listening-card.tsx`, `weather-card.tsx` | +| **Nav** | **About · Projects · Misc** — `app/components/nav.tsx` (active link accent + underline) | +| **Footer** | `app/components/site-footer.tsx` in root layout — every page | | **Typography** | Geist `font-sans` on `body`; **Nunito** for nav + `.mag-card` / `.mag-label` (`app/globals.css`) | -| **Notes** | External Notion page linked from navigation | | **Observability** | Sentry via `instrumentation*.ts` + `sentry.*.config.ts` (DSN optional); Vercel Analytics / Speed Insights in root layout | | **Theme** | `app/components/theme-provider.tsx` + inline script in `app/layout.tsx` — default **light** when unset | @@ -44,7 +45,7 @@ npm run lint && npm run typecheck && npm run test && npm run build - [`lib/spotify-now-playing-helpers.ts`](lib/spotify-now-playing-helpers.ts) — pure helpers used by that route (tested) - [`lib/listening-supabase.ts`](lib/listening-supabase.ts) — optional Supabase reads/writes for listening history - [`lib/weather-open-meteo.ts`](lib/weather-open-meteo.ts) — Open-Meteo payload parsing (tested) -- [`app/lib/substack.ts`](app/lib/substack.ts) — RSS fetch + parse (tested) +- [`app/components/pinned-project-link.tsx`](app/components/pinned-project-link.tsx) — shared pinned-repo markup (home list + projects cards) - [`app/page.tsx`](app/page.tsx) — home layout, social URLs, cards - [`app/globals.css`](app/globals.css) — `.mag-card`, `.mag-label`, theme surfaces - [`next.config.ts`](next.config.ts) — image remote patterns, `withSentryConfig` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cd94913..2fec594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,7 +115,7 @@ If you touch **OAuth callbacks**, **API routes**, or **Supabase policies**, coor ## Forking for your own site -See the **Forking this project** section in [README.md](README.md) for a checklist of files to replace (Spotify app, GitHub repos, Supabase, Substack, etc.). +See the **Forking this project** section in [README.md](README.md) for a checklist of files to replace (Spotify app, GitHub repos, Supabase, etc.). --- diff --git a/README.md b/README.md index fa0bf9d..e16e450 100644 --- a/README.md +++ b/README.md @@ -89,14 +89,14 @@ To do that, the site mixes several kinds of content: | Kind | Purpose | Examples on this site | | -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Narrative** | Background and intent that change slowly | About page (education, focus, experience, volunteering); project write-ups | -| **Live signals** | Small windows into the present | Spotify **now playing** (polled every ~10s); Berkeley **weather + local clock**; GitHub **pinned repos** and **contribution calendar**; Substack **latest posts** via RSS | -| **Curated lists** | Taste and attention, hand-picked | Misc page (news, tools & creators, remembrance) | -| **External writing** | Longer thinking lives elsewhere | **Notes** on Notion and **Blog** on Substack — linked from nav, not duplicated here | +| **Live signals** | Small windows into the present | Spotify **now playing** (polled every ~10s); Berkeley **weather + local clock**; GitHub **pinned repos** and **contribution calendar** | +| **Curated lists** | Taste and attention, hand-picked | Misc page (**Watching**, tools & creators, remembrance) | +| **External writing** | Longer thinking lives elsewhere | Substack and Notion linked from social / JSON-LD — not duplicated or RSS-fed on the home page | Visually, the site uses an **editorial / magazine** layout: cream-toned cards (`.mag-card`), Nunito for nav and card chrome, Geist Sans for body text, light mode by default with full dark-mode support. The goal is reading comfort and personality — a hub you can revisit, not a feature dashboard. -This repository implements that idea with **Next.js 16** (App Router), **React 19**, **TypeScript**, **Tailwind CSS 4**, deployed on **Vercel**. Dynamic data comes from **Spotify**, **GitHub GraphQL**, **Open-Meteo**, optional **Supabase** listening history, and **Substack RSS**. Optional observability: **Sentry**, **Vercel Analytics**, **Speed Insights**. +This repository implements that idea with **Next.js 16** (App Router), **React 19**, **TypeScript**, **Tailwind CSS 4**, deployed on **Vercel**. Dynamic data comes from **Spotify**, **GitHub GraphQL**, **Open-Meteo**, and optional **Supabase** listening history. Optional observability: **Sentry**, **Vercel Analytics**, **Speed Insights**. There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rendered directly by the App Router. @@ -105,12 +105,10 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende | Route / link | Role | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `**/`** | First impression — identity, Giving What We Can pledge mark, social links (Email, Signal, GitHub, LinkedIn, Spotify), **Listening** card (Spotify), **Location** card (Open-Meteo weather + `America/Los_Angeles` clock, °C/°F toggle), pinned GitHub repos, Substack headlines | -| `**/about`** | Deeper self — personality, education, current **Focus**, experience, volunteering | -| `**/projects`** | Work in the world — GitHub pinned project cards (stars / archived badges) + contribution heatmap | -| `**/misc**` | Side of the mind — curated **News**, **Things I Love** (tools & creators), **Remembrance** | -| **Notes** (nav) | External Notion site — longer notes, not hosted in this repo | -| **Blog** (nav) | External Substack — essays linked from home RSS snippets | +| `**/`** | First impression — identity, Giving What We Can pledge mark, social links (Email, Signal, GitHub, LinkedIn, Spotify), **Listening** card (Spotify), **Location** card (Open-Meteo weather + `America/Los_Angeles` clock, °C/°F toggle), pinned GitHub repos | +| `**/about`** | Deeper self — personality, education, current **Focus**, experience, volunteering | +| `**/projects`** | Work in the world — GitHub pinned project cards (stars / archived badges) + contribution heatmap | +| `**/misc**` | Side of the mind — curated **Watching**, **Things I Love** (tools & creators), **Remembrance** | **Open Graph:** each in-app route has an `opengraph-image` handler (`[app/lib/og.tsx](app/lib/og.tsx)`). **SEO:** `sitemap.ts`, `robots.ts`, and JSON-LD `Person` on the home page. @@ -196,9 +194,9 @@ Pinned versions: `[package.json](package.json)`. ```text kaichen.dev/ ├── app/ -│ ├── layout.tsx # Root layout, metadata, theme, Nav, Analytics -│ ├── page.tsx # Home (identity, Spotify, weather, GitHub, Substack) -│ ├── globals.css # Theme tokens, .mag-card, dark mode +│ ├── layout.tsx # Root layout, metadata, theme, Nav, SiteFooter, Analytics +│ ├── page.tsx # Home (identity, Spotify, weather, GitHub) +│ ├── globals.css # Theme tokens, .mag-card, animations │ ├── opengraph-image.tsx # OG for / │ ├── about/ # Bio, education, focus, experience + OG │ ├── projects/ # Pinned repos, contribution heatmap + OG @@ -207,9 +205,9 @@ kaichen.dev/ │ │ ├── spotify/now-playing/ │ │ ├── github/contributions/ │ │ └── weather/ -│ ├── components/ # nav, listening-card, weather-card, theme, … +│ ├── components/ # nav, site-footer, listening-card, weather-card, theme, … │ ├── hooks/use-now-playing.ts -│ ├── lib/ # og.tsx, substack.ts, github-pinned.ts +│ ├── lib/ # og.tsx, github-pinned.ts │ ├── sitemap.ts │ └── robots.ts ├── lib/ # Server helpers + Vitest tests @@ -233,13 +231,13 @@ kaichen.dev/ | Route | What it does | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/` | Identity + GWWC mark; social links; **Listening** (Spotify via `[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`, ~10s poll, `cache: "no-store"`); **Location** (Open-Meteo via `GET /api/weather`, °C/°F, feels-like, humidity, rain chance, `[berkeley-time.tsx](app/components/berkeley-time.tsx)`); pinned GitHub repos (`[github-pinned.ts](app/lib/github-pinned.ts)`, GraphQL + static fallback); Substack RSS (`[substack.ts](app/lib/substack.ts)`) | -| `/about` | Personality, education, Focus, experience, volunteering | -| `/projects` | Pinned project cards + GitHub contribution calendar (`[/api/github/contributions](app/api/github/contributions/route.ts)`) | -| `/misc` | News, Things I Love, Remembrance | +| `/` | Identity + GWWC mark; social links; **Listening** (Spotify via `[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`, ~10s poll, `cache: "no-store"`); **Location** (Open-Meteo via `GET /api/weather`, °C/°F, feels-like, humidity, rain chance, `[berkeley-time.tsx](app/components/berkeley-time.tsx)`); pinned GitHub repos (`[github-pinned.ts](app/lib/github-pinned.ts)`, GraphQL + static fallback) | +| `/about` | Personality, education, Focus, experience, volunteering | +| `/projects` | Pinned project cards + GitHub contribution calendar (`[/api/github/contributions](app/api/github/contributions/route.ts)`) | +| `/misc` | Watching, Things I Love, Remembrance | -**External nav:** **About · Projects · Notes · Blog · Misc** — Notes → Notion, Blog → Substack (no `/notes` or `/blog` in-repo). +**Nav:** **About · Projects · Misc** — site footer on every page: `© 2026 Kai Thomas Chen. All rights reserved.` #### API routes @@ -249,6 +247,7 @@ All handlers under `app/api/`. | Method & path | Behavior | Caching / env | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | `GET /api/spotify/now-playing` | Spotify `currently-playing` + `recently-played`; optional Supabase `listening_`* writes while playing; in-memory `lastKnownTrack` fallback | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`; `SPOTIFY_*`; optional `SUPABASE_*` | +| `GET /api/spotify/last-month-top` | Previous calendar month top **5** tracks from Supabase `listening_history` (`recorded_at`, paginated) | `revalidate = 600`; optional `SUPABASE_*` | | `GET /api/github/contributions` | GraphQL contribution calendar (12 months) → `{ weeks, totalContributions }` | `revalidate = 300`; `GITHUB_TOKEN` | | `GET /api/weather` | Open-Meteo current conditions for Berkeley, CA (`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`; no API key | @@ -286,7 +285,6 @@ vercel env pull .env.vercel.check # gitignored — do not commit | **GitHub GraphQL** | Contribution calendar + pinned repos (stars, archived) | | **Open-Meteo** | Berkeley weather (no key) | | **Supabase** | Optional listening history persistence | -| **Substack RSS** | Home page latest posts | #### Local development tips @@ -301,7 +299,7 @@ vercel env pull .env.vercel.check # gitignored — do not commit #### Testing -Vitest unit tests in `lib/*.test.ts` — Substack RSS, weather parsing, Spotify helpers. +Vitest unit tests in `lib/*.test.ts` — weather parsing and Spotify helpers. ```bash npm run test @@ -339,7 +337,6 @@ vercel --prod # after vercel link | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*` in `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | | GitHub login / pins | `GITHUB_LOGIN`, `[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`, contributions route | | Supabase schema | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`, now-playing route | -| Substack feed | `[app/lib/substack.ts](app/lib/substack.ts)` | | Weather location | `[app/api/weather/route.ts](app/api/weather/route.ts)` | | Misc lists | `[app/misc/page.tsx](app/misc/page.tsx)` | | Theme / fonts | `app/layout.tsx`, `app/globals.css`, `theme-provider.tsx` | @@ -410,14 +407,14 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | 类型 | 作用 | 在本站的具体体现 | | -------- | ---------------- | -------------------------------------------------------------------------------------------------- | | **叙述** | 变化较慢的背景与方向 | About(教育、Focus、经历、志愿);Projects 项目说明 | -| **实时信号** | 照进「此刻」的小窗口 | Spotify **正在播放**(约每 10s 轮询);Berkeley **天气 + 本地时钟**;GitHub **置顶仓库**与**贡献日历**;Substack **最新文章**(RSS) | -| **策展列表** | 品味与注意力,人工挑选 | Misc(新闻、工具与创作者、纪念) | -| **站外长文** | 更长思考托管 elsewhere | 导航链至 **Notion Notes** 与 **Substack Blog**,不在本仓库重复搬运 | +| **实时信号** | 照进「此刻」的小窗口 | Spotify **正在播放**(约每 10s 轮询);Berkeley **天气 + 本地时钟**;GitHub **置顶仓库**与**贡献日历** | +| **策展列表** | 品味与注意力,人工挑选 | Misc(**Watching**、工具与创作者、纪念) | +| **站外长文** | 更长思考托管 elsewhere | Substack / Notion 通过社交链接与 JSON-LD 引用 —— 首页不展示 RSS 摘要 | 视觉上采用**杂志 / editorial** 排版:奶油色卡片(`.mag-card`)、Nunito 用于导航与卡片 chrome、Geist Sans 用于正文、默认浅色并完整支持暗色。目标是阅读舒适与个性 —— 可回访的**枢纽**,而非功能控制台。 -技术实现:**Next.js 16**(App Router)、**React 19**、**TypeScript**、**Tailwind CSS 4**,部署于 **Vercel**。动态数据来自 **Spotify**、**GitHub GraphQL**、**Open-Meteo**、可选 **Supabase** 听歌记录、**Substack RSS**。可选观测:**Sentry**、**Vercel Analytics**、**Speed Insights**。 +技术实现:**Next.js 16**(App Router)、**React 19**、**TypeScript**、**Tailwind CSS 4**,部署于 **Vercel**。动态数据来自 **Spotify**、**GitHub GraphQL**、**Open-Meteo**、可选 **Supabase** 听歌记录。可选观测:**Sentry**、**Vercel Analytics**、**Speed Insights**。 本仓库**没有** `middleware.ts` / `proxy.ts`,所有路由公开,由 App Router 直接渲染。 @@ -426,12 +423,10 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | 路由 / 链接 | 作用 | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `**/`** | 第一印象 —— 身份、Giving What We Can 标识、社交链接(Email、Signal、GitHub、LinkedIn、Spotify)、**Listening** 卡片(Spotify)、**Location** 卡片(Open-Meteo 天气 + `America/Los_Angeles` 时钟、°C/°F 切换)、GitHub 置顶仓库、Substack 摘要 | +| `**/`** | 第一印象 —— 身份、Giving What We Can 标识、社交链接(Email、Signal、GitHub、LinkedIn、Spotify)、**Listening** 卡片(Spotify)、**Location** 卡片(Open-Meteo 天气 + `America/Los_Angeles` 时钟、°C/°F 切换)、GitHub 置顶仓库 | | `**/about`** | 更深的自我 —— 性格、教育、当前 **Focus**、经历、志愿 | | `**/projects`** | 留在世界上的工作 —— GitHub 置顶项目(star / archived 徽章)+ 贡献热力图 | -| `**/misc**` | 心智侧面 —— 策展 **News**、**Things I Love**(工具与创作者)、**Remembrance** | -| **Notes**(导航) | 外链 Notion —— 长笔记,不在本仓库托管 | -| **Blog**(导航) | 外链 Substack —— 首页 RSS 摘要链出全文 | +| `**/misc**` | 心智侧面 —— 策展 **Watching**、**Things I Love**(工具与创作者)、**Remembrance** | **Open Graph:** 各站内路由均有 `opengraph-image`(`[app/lib/og.tsx](app/lib/og.tsx)`)。**SEO:** `sitemap.ts`、`robots.ts`,首页嵌入 JSON-LD `Person`。 @@ -521,9 +516,9 @@ npm run lint && npm run typecheck && npm run test && npm run build ```text kaichen.dev/ ├── app/ -│ ├── layout.tsx # 根布局、metadata、主题、Nav、Analytics -│ ├── page.tsx # 首页(身份、Spotify、天气、GitHub、Substack) -│ ├── globals.css # 主题 token、.mag-card、暗色模式 +│ ├── layout.tsx # 根布局、metadata、主题、Nav、SiteFooter、Analytics +│ ├── page.tsx # 首页(身份、Spotify、天气、GitHub) +│ ├── globals.css # 主题 token、.mag-card、动画 │ ├── opengraph-image.tsx # 首页 OG │ ├── about/ # 简介、教育、Focus、经历 + OG │ ├── projects/ # 置顶仓库、贡献热力图 + OG @@ -532,9 +527,9 @@ kaichen.dev/ │ │ ├── spotify/now-playing/ │ │ ├── github/contributions/ │ │ └── weather/ -│ ├── components/ # nav、listening-card、weather-card、theme 等 +│ ├── components/ # nav、site-footer、listening-card、weather-card、theme 等 │ ├── hooks/use-now-playing.ts -│ ├── lib/ # og.tsx、substack.ts、github-pinned.ts +│ ├── lib/ # og.tsx、github-pinned.ts │ ├── sitemap.ts │ └── robots.ts ├── lib/ # 服务端 helper + Vitest 测试 @@ -558,13 +553,13 @@ kaichen.dev/ | 路由 | 功能 | | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/` | 身份 + GWWC 标识;社交链接;**Listening**(Spotify,`[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`,约 10s 轮询,`cache: "no-store"`);**Location**(Open-Meteo,`GET /api/weather`,°C/°F、体感、湿度、降水概率,`[berkeley-time.tsx](app/components/berkeley-time.tsx)`);GitHub 置顶仓库(`[github-pinned.ts](app/lib/github-pinned.ts)`,GraphQL + 静态兜底);Substack RSS(`[substack.ts](app/lib/substack.ts)`) | +| `/` | 身份 + GWWC 标识;社交链接;**Listening**(Spotify,`[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`,约 10s 轮询,`cache: "no-store"`);**Location**(Open-Meteo,`GET /api/weather`,°C/°F、体感、湿度、降水概率,`[berkeley-time.tsx](app/components/berkeley-time.tsx)`);GitHub 置顶仓库(`[github-pinned.ts](app/lib/github-pinned.ts)`,GraphQL + 静态兜底) | | `/about` | 性格、教育、Focus、经历、志愿 | | `/projects` | 置顶项目卡片 + GitHub 贡献日历(`[/api/github/contributions](app/api/github/contributions/route.ts)`) | -| `/misc` | News、Things I Love、Remembrance | +| `/misc` | Watching、Things I Love、Remembrance | -**站外导航:** **About · Projects · Notes · Blog · Misc** —— Notes 指向 Notion,Blog 指向 Substack(仓库内无 `/notes`、`/blog` 路由)。 +**导航:** **About · Projects · Misc** —— 每页页脚:`© 2026 Kai Thomas Chen. All rights reserved.` #### API 路由 @@ -574,6 +569,7 @@ kaichen.dev/ | 方法与路径 | 行为 | 缓存 / 环境变量 | | ------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `GET /api/spotify/now-playing` | Spotify 当前播放 + 最近播放;播放中可选写入 Supabase `listening_`*;内存 `lastKnownTrack` 兜底 | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`;`SPOTIFY_*`;可选 `SUPABASE_*` | +| `GET /api/spotify/last-month-top` | 上自然月 Top **5**(Supabase `listening_history`,按 `recorded_at` 筛选并分页) | `revalidate = 600`;可选 `SUPABASE_*` | | `GET /api/github/contributions` | GraphQL 贡献日历(12 个月)→ `{ weeks, totalContributions }` | `revalidate = 300`;`GITHUB_TOKEN` | | `GET /api/weather` | Open-Meteo 伯克利当前天气(`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`;无需 API key | @@ -611,7 +607,6 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交 | **GitHub GraphQL** | 贡献日历 + 置顶仓库(star、archived) | | **Open-Meteo** | 伯克利天气(无需 key) | | **Supabase** | 可选听歌记录持久化 | -| **Substack RSS** | 首页最新文章 | #### 常见问题 @@ -626,7 +621,7 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交 #### 测试 -Vitest 单元测试位于 `lib/*.test.ts` —— Substack RSS、天气解析、Spotify helper。 +Vitest 单元测试位于 `lib/*.test.ts` —— 天气解析、Spotify helper。 ```bash npm run test @@ -666,7 +661,6 @@ vercel --prod # 需先 vercel link | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*`,见 `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | | GitHub 用户 / 置顶 | `GITHUB_LOGIN`、`[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`、contributions 路由 | | Supabase 表结构 | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`、now-playing 路由 | -| Substack 源 | `[app/lib/substack.ts](app/lib/substack.ts)` | | 天气坐标 | `[app/api/weather/route.ts](app/api/weather/route.ts)` | | Misc 列表 | `[app/misc/page.tsx](app/misc/page.tsx)` | | 主题 / 字体 | `app/layout.tsx`、`app/globals.css`、`theme-provider.tsx` | diff --git a/app/about/page.tsx b/app/about/page.tsx index a7237c4..e835d67 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -3,7 +3,7 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "About — Kai T. Chen", description: - "Education, current focus, experience, and volunteering — Maths at SUSTech, visiting UC Berkeley.", + "Education, current focus, experience, and volunteering — Maths at SUSTech, visiting at UC Berkeley.", }; export default function About() { @@ -22,7 +22,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.

diff --git a/app/api/spotify/last-month-top/route.ts b/app/api/spotify/last-month-top/route.ts new file mode 100644 index 0000000..9fabc08 --- /dev/null +++ b/app/api/spotify/last-month-top/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { + createListeningSupabase, + getLastMonthTopFiveTracks, +} from "@/lib/listening-supabase"; + +export const revalidate = 600; + +export async function GET() { + const db = createListeningSupabase(); + const tracks = await getLastMonthTopFiveTracks(db); + + return NextResponse.json( + { tracks }, + { + headers: { + "Cache-Control": "public, s-maxage=600, stale-while-revalidate=120", + }, + } + ); +} diff --git a/app/components/gwwc-badge.tsx b/app/components/gwwc-badge.tsx new file mode 100644 index 0000000..980bdb5 --- /dev/null +++ b/app/components/gwwc-badge.tsx @@ -0,0 +1,28 @@ +"use client"; + +import HoverTip from "./hover-tip"; + +export default function GwwcBadge() { + return ( + + Giving What We Can — 10% Pledge Member. + I commit to donate at least 10% of my income to effective charities. + + } + placement="top" + tipClassName="max-w-[280px] px-3.5 py-2.5 text-[13px] leading-relaxed text-left" + > + + 🔸 + + + ); +} diff --git a/app/components/home-social-links.tsx b/app/components/home-social-links.tsx new file mode 100644 index 0000000..00377ca --- /dev/null +++ b/app/components/home-social-links.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { ReactNode } from "react"; +import HoverTip from "./hover-tip"; + +const SOCIAL_LINKS: Array<{ + href: string; + label: string; + ariaLabel: string; + external?: boolean; + hoverClass: string; + icon: ReactNode; +}> = [ + { + href: "mailto:kaichen.dev.37@gmail.com", + label: "Email", + ariaLabel: "Email", + hoverClass: + "hover:text-[#EA4335] dark:hover:text-[#FCA5A5]", + icon: ( + + + + + ), + }, + { + href: "https://signal.me/#eu/CNyQcDgQ0KabVX2JyPske78Rwe0HQVHAstI2x1WyyfY_ivPGcChGG44wbzDzi23D", + label: "Signal", + ariaLabel: "Signal", + external: true, + hoverClass: "hover:text-[#3B45FD] dark:hover:text-[#9DBBF8]", + icon: ( + + + + + ), + }, + { + href: "https://github.com/kaiiiichen", + label: "GitHub", + ariaLabel: "GitHub", + external: true, + hoverClass: "hover:text-[#181717] dark:hover:text-white", + icon: ( + + + + ), + }, + { + href: "https://linkedin.com/in/kaiiiichen", + label: "LinkedIn", + ariaLabel: "LinkedIn", + external: true, + hoverClass: "hover:text-[#0A66C2] dark:hover:text-[#70B5F9]", + icon: ( + + + + ), + }, + { + href: "https://open.spotify.com/user/31xktcnl7hl34lu4windhu5uwji4?si=46cd7158fc2a4ab0", + label: "Spotify", + ariaLabel: "Spotify profile", + external: true, + hoverClass: "hover:text-[#1DB954] dark:hover:text-[#1ED760]", + icon: ( + + + + ), + }, +]; + +export default function HomeSocialLinks() { + const linkClass = + "text-zinc-700 dark:text-zinc-300 opacity-50 transition-all duration-300 ease-out hover:opacity-100 [&_svg]:size-7"; + + return ( +
+ {SOCIAL_LINKS.map(({ href, label, ariaLabel, external, hoverClass, icon }) => ( + + {external ? ( + + {icon} + + ) : ( + + {icon} + + )} + + ))} + + + + +
+ ); +} diff --git a/app/components/hover-tip.tsx b/app/components/hover-tip.tsx new file mode 100644 index 0000000..1e88587 --- /dev/null +++ b/app/components/hover-tip.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; + +type HoverTipProps = { + tip: ReactNode; + children: ReactNode; + /** Tooltip placement relative to trigger */ + placement?: "top" | "bottom"; + /** Horizontal alignment of tooltip relative to trigger */ + align?: "center" | "end"; + /** Allow hovering / clicking links inside the tooltip */ + interactive?: boolean; + /** Render tooltip in a body portal (escapes overflow:hidden ancestors) */ + portal?: boolean; + className?: string; + tipClassName?: string; +}; + +const TIP_BASE_CLASS = + "rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)] transition-all duration-150"; + +function tipVisibilityClass(open: boolean) { + return open + ? "opacity-100 translate-y-0 pointer-events-auto" + : "opacity-0 translate-y-0.5 pointer-events-none"; +} + +function portalFixedStyle( + coords: DOMRect, + placement: "top" | "bottom", + align: "center" | "end" +): CSSProperties { + const gap = 8; + const base: CSSProperties = { + position: "fixed", + zIndex: 99999, + fontFamily: "'Nunito'", + fontWeight: 400, + }; + + if (placement === "top" && align === "end") { + return { + ...base, + top: coords.top - gap, + right: window.innerWidth - coords.right, + transform: "translateY(-100%)", + }; + } + if (placement === "top" && align === "center") { + return { + ...base, + top: coords.top - gap, + left: coords.left + coords.width / 2, + transform: "translate(-50%, -100%)", + }; + } + if (placement === "bottom" && align === "end") { + return { + ...base, + top: coords.bottom + gap, + right: window.innerWidth - coords.right, + }; + } + return { + ...base, + top: coords.bottom + gap, + left: coords.left + coords.width / 2, + transform: "translateX(-50%)", + }; +} + +export default function HoverTip({ + tip, + children, + placement = "top", + align = "center", + interactive = false, + portal = false, + className = "", + tipClassName = "", +}: HoverTipProps) { + const triggerRef = useRef(null); + const hideTimerRef = useRef | null>(null); + const [mounted, setMounted] = useState(false); + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState(null); + + useEffect(() => { + queueMicrotask(() => setMounted(true)); + }, []); + + const syncCoords = useCallback(() => { + const el = triggerRef.current; + if (!el) return; + setCoords(el.getBoundingClientRect()); + }, []); + + const show = useCallback(() => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + syncCoords(); + setOpen(true); + }, [syncCoords]); + + const hide = useCallback(() => { + setOpen(false); + }, []); + + const scheduleHide = useCallback(() => { + if (!interactive) { + hide(); + return; + } + hideTimerRef.current = setTimeout(hide, 120); + }, [hide, interactive]); + + useEffect(() => { + if (!open || !portal) return; + const onUpdate = () => syncCoords(); + window.addEventListener("scroll", onUpdate, true); + window.addEventListener("resize", onUpdate); + return () => { + window.removeEventListener("scroll", onUpdate, true); + window.removeEventListener("resize", onUpdate); + }; + }, [open, portal, syncCoords]); + + useEffect( + () => () => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }, + [] + ); + + if (portal && mounted) { + const fixedStyle = coords + ? portalFixedStyle(coords, placement, align) + : { position: "fixed" as const, visibility: "hidden" as const }; + + return ( + <> + + {children} + + {createPortal( + + {tip} + , + document.body + )} + + ); + } + + const position = + placement === "top" + ? align === "end" + ? "bottom-full right-0 mb-2" + : "bottom-full left-1/2 -translate-x-1/2 mb-2" + : align === "end" + ? "top-full right-0 mt-2" + : "top-full left-1/2 -translate-x-1/2 mt-2"; + + return ( + + {children} + + {tip} + + + ); +} diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx index 2f43b1c..3847bbf 100644 --- a/app/components/listening-card.tsx +++ b/app/components/listening-card.tsx @@ -1,7 +1,9 @@ "use client"; -import Image from "next/image"; + import { useNowPlaying } from "@/app/hooks/use-now-playing"; import type { CSSProperties } from "react"; +import ListeningLastMonthTop from "./listening-last-month-top"; +import ListeningTrackRow from "./listening-track-row"; // Spotify's audio-features API was retired (403 for this app), so the live dot // uses fixed timings instead of per-track BPM/intensity. @@ -41,82 +43,20 @@ export default function ListeningCard() { const stateText = live ? "now playing" : hasTrack ? "last played" : "idle"; - // Ring around the album art only appears while hovering the track row, in - // both "now playing" and "last played" states. - const hoverRing = - "ring-0 group-hover/track:ring-[1.5px] " + - "group-hover/track:ring-[#C4894F]/45 group-hover/track:ring-offset-[2px] " + - "group-hover/track:ring-offset-white " + - "dark:group-hover/track:ring-[#D9A870]/40 dark:group-hover/track:ring-offset-[#252019]"; - return (
-
+
{!displayItem ? ( ) : ( - - - ↗ - - - @@ -149,9 +89,11 @@ export default function ListeningCard() { > {stateText} +
+ +
-
); } diff --git a/app/components/listening-last-month-top.tsx b/app/components/listening-last-month-top.tsx new file mode 100644 index 0000000..bd98947 --- /dev/null +++ b/app/components/listening-last-month-top.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ListeningHighlightTrack } from "@/lib/listening-monthly-top"; +import HoverTip from "./hover-tip"; +import ListeningTrackRow from "./listening-track-row"; + +export default function ListeningLastMonthTop() { + const [tracks, setTracks] = useState(null); + + useEffect(() => { + let cancelled = false; + fetch("/api/spotify/last-month-top") + .then((r) => r.json()) + .then((body: { tracks?: ListeningHighlightTrack[] }) => { + if (!cancelled) setTracks(body.tracks ?? []); + }) + .catch(() => { + if (!cancelled) setTracks([]); + }); + return () => { + cancelled = true; + }; + }, []); + + if (tracks === null) return null; + + return ( + + + last month · top 5 + + {tracks.length === 0 ? ( + + No plays recorded last month. + + ) : ( + tracks.map((track) => ( + + )) + )} + + } + > + + + ); +} diff --git a/app/components/listening-track-row.tsx b/app/components/listening-track-row.tsx new file mode 100644 index 0000000..d90b0b2 --- /dev/null +++ b/app/components/listening-track-row.tsx @@ -0,0 +1,95 @@ +import Image from "next/image"; + +const HOVER_RING = + "ring-0 group-hover/track:ring-[1.5px] " + + "group-hover/track:ring-[#C4894F]/45 group-hover/track:ring-offset-[2px] " + + "group-hover/track:ring-offset-white " + + "dark:group-hover/track:ring-[#D9A870]/40 dark:group-hover/track:ring-offset-[#252019]"; + +type ListeningTrackRowProps = { + title: string; + artist: string; + albumArt: string; + songUrl: string; + live?: boolean; + compact?: boolean; + className?: string; +}; + +export default function ListeningTrackRow({ + title, + artist, + albumArt, + songUrl, + live = false, + compact = false, + className = "", +}: ListeningTrackRowProps) { + const artSize = compact ? 32 : 40; + const artClass = compact ? "size-8" : "size-10"; + + return ( + + + ↗ + + +
+ {albumArt ? ( + {`${title} + ) : ( +
+ )} +
+ +
+

+ {title} +

+

+ {artist} +

+
+
+ ); +} diff --git a/app/components/nav.tsx b/app/components/nav.tsx index 9abf192..3e9fe17 100644 --- a/app/components/nav.tsx +++ b/app/components/nav.tsx @@ -1,50 +1,35 @@ "use client"; import Link from "next/link"; +import { usePathname } from "next/navigation"; import ThemeToggle from "./theme-toggle"; -import { useState, useEffect, useRef, useCallback } from "react"; -import { createPortal } from "react-dom"; - -const WAVE_SVG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='4'%3E%3Cpath d='M0 3 Q5 0 10 3 Q15 6 20 3' stroke='%23C4894F' stroke-width='1.5' fill='none'/%3E%3C/svg%3E")`; +import { useState, useEffect, useRef } from "react"; const NAV_LINKS = [ - { href: "/about", label: "About", external: false }, - { href: "/projects", label: "Projects", external: false }, - { - href: "https://kaichen-dev.notion.site/kaichen-dev-notion-site-357a3294e48780f3b570f2f48f1081a4?pvs=74", - label: "Notes", - external: true, - }, - { href: "https://kaiiiichen.substack.com/", label: "Blog", external: true }, - { href: "/misc", label: "Misc", external: false }, + { href: "/about", label: "About" }, + { href: "/projects", label: "Projects" }, + { href: "/misc", label: "Misc" }, ]; +function isNavActive(pathname: string, href: string) { + if (href === "/") return pathname === "/"; + return pathname === href || pathname.startsWith(`${href}/`); +} + +/** Active nav — accent text + 2px underline; hover — text highlight only */ +function navLinkClassName(active: boolean) { + const base = + "text-sm transition-colors duration-150 pb-0.5 border-b-2"; + if (active) { + return `${base} text-[#C4894F] dark:text-[#D9A870] border-[#C4894F] dark:border-[#D9A870]`; + } + return `${base} text-zinc-500 dark:text-zinc-400 border-transparent hover:text-[#C4894F] dark:hover:text-[#D9A870]`; +} + export default function Nav() { - const [mounted, setMounted] = useState(false); + const pathname = usePathname(); 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(); - cancelAnimationFrame(waveLeaveFrame.current); - setWaveRect({ left: r.left, width: r.width, top: r.bottom + 2 }); - }, []); - - const onWaveLeave = useCallback(() => { - waveLeaveFrame.current = requestAnimationFrame(() => setWaveRect(null)); - }, []); - - const waveProps = { - onMouseEnter: onWaveEnter, - onMouseLeave: onWaveLeave, - }; useEffect(() => { if (!isOpen) return; @@ -58,45 +43,29 @@ export default function Nav() { }, [isOpen]); return ( - <> - {mounted - ? createPortal( -
, - document.body - ) - : null} - ); } diff --git a/app/components/pinned-project-link.tsx b/app/components/pinned-project-link.tsx new file mode 100644 index 0000000..a2481f4 --- /dev/null +++ b/app/components/pinned-project-link.tsx @@ -0,0 +1,116 @@ +import ProjectStars from "./project-stars"; +import type { PinnedProject } from "@/app/lib/github-pinned"; + +type PinnedProjectLinkProps = PinnedProject & { + variant?: "list" | "card"; +}; + +export default function PinnedProjectLink({ + name, + desc, + href, + stack, + stars, + archived, + variant = "list", +}: PinnedProjectLinkProps) { + 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'", + fontWeight: 600, + fontSize: 20, + fontStyle: "italic" as const, + }; + + if (variant === "card") { + return ( + +
+ + ↗ + +

+ {name} +

+ +
+ + {desc ? ( +

+ {desc} +

+ ) : null} + +
+
+ {stack.map((tag) => ( + + {tag} + + ))} +
+ + GitHub ↗ + +
+
+ ); + } + + return ( + +
+ + ↗ + +

+ {name} +

+ +
+ {desc ? ( +

+ {desc} +

+ ) : null} + {stack.length > 0 ? ( +
+ {stack.map((tag) => ( + + {tag} + + ))} +
+ ) : null} +
+ ); +} diff --git a/app/components/project-stars.tsx b/app/components/project-stars.tsx index 2e0dc25..2bc5782 100644 --- a/app/components/project-stars.tsx +++ b/app/components/project-stars.tsx @@ -15,9 +15,6 @@ export default function ProjectStars({ stars, archived }: ProjectStarsProps) { return ( <> - - public - {archived && ( archived diff --git a/app/components/site-footer.tsx b/app/components/site-footer.tsx new file mode 100644 index 0000000..5578d10 --- /dev/null +++ b/app/components/site-footer.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; + +export default function SiteFooter() { + return ( +
+
+

+ © 2026{" "} + + Kai Thomas Chen + + . All rights reserved. +

+
+
+ ); +} diff --git a/app/components/theme-provider.tsx b/app/components/theme-provider.tsx index e0867e1..5723f7c 100644 --- a/app/components/theme-provider.tsx +++ b/app/components/theme-provider.tsx @@ -9,18 +9,11 @@ import { useState, } from "react"; -type Theme = "light" | "dark" | "system"; +type Theme = "light" | "dark"; const STORAGE_KEY = "theme"; -function getSystemTheme(): "light" | "dark" { - if (typeof window === "undefined") return "light"; - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; -} - -function applyResolved(resolved: "light" | "dark") { +function applyResolved(resolved: Theme) { const root = document.documentElement; root.classList.toggle("dark", resolved === "dark"); root.style.colorScheme = resolved; @@ -29,22 +22,20 @@ function applyResolved(resolved: "light" | "dark") { type ThemeContextValue = { theme: Theme; setTheme: (t: Theme) => void; - resolvedTheme: "light" | "dark"; + resolvedTheme: Theme; }; const ThemeContext = createContext(null); export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setThemeState] = useState("system"); + const [theme, setThemeState] = useState("light"); useEffect(() => { queueMicrotask(() => { try { - const s = localStorage.getItem(STORAGE_KEY) as Theme | null; - if (s === "light" || s === "dark" || s === "system") { - setThemeState(s); - } else { - setThemeState("light"); + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") { + setThemeState(stored); } } catch { /* ignore */ @@ -52,19 +43,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }); }, []); - const resolvedTheme: "light" | "dark" = - theme === "system" ? getSystemTheme() : theme; - - useEffect(() => { - applyResolved(resolvedTheme); - }, [resolvedTheme]); - useEffect(() => { - if (theme !== "system") return; - const mq = window.matchMedia("(prefers-color-scheme: dark)"); - const onChange = () => applyResolved(getSystemTheme()); - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); + applyResolved(theme); }, [theme]); const setTheme = useCallback((t: Theme) => { @@ -74,12 +54,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { } catch { /* ignore */ } - applyResolved(t === "system" ? getSystemTheme() : t); + applyResolved(t); }, []); const value = useMemo( - () => ({ theme, setTheme, resolvedTheme }), - [theme, setTheme, resolvedTheme] + () => ({ theme, setTheme, resolvedTheme: theme }), + [theme, setTheme] ); return ( diff --git a/app/components/weather-card.tsx b/app/components/weather-card.tsx index 3b2baec..5c1f076 100644 --- a/app/components/weather-card.tsx +++ b/app/components/weather-card.tsx @@ -8,7 +8,6 @@ type WeatherData = { feelsLike: number; humidity: number; weatherCode: number; - emoji: string; condition: string; rainChance: number; }; @@ -38,7 +37,7 @@ export default function WeatherCard() { style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: 14, letterSpacing: "0.02em" }} className="text-zinc-600 dark:text-zinc-400 leading-[1.1]" > - Berkeley · CA + Based in Berkeley · CA

{w - ? `feels ${isCelsius ? `${w.feelsLike}°C` : `${Math.round(w.feelsLike * 9 / 5 + 32)}°F`} · humidity ${w.humidity}%` - : "Feels — · Humidity —"} + ? `feels ${isCelsius ? `${w.feelsLike}°C` : `${Math.round(w.feelsLike * 9 / 5 + 32)}°F`} · humidity ${w.humidity}% · rain ${w.rainChance}%` + : "Feels — · Humidity — · Rain —"}

diff --git a/app/globals.css b/app/globals.css index 50cd875..179ce77 100644 --- a/app/globals.css +++ b/app/globals.css @@ -6,20 +6,19 @@ :root { --background: #F7F3EE; --foreground: #171717; - --dot-idle: #a1a1aa; --accent: #C4894F; --contribution-empty: #e4e4e7; --contribution-l1: #c6e48b; --contribution-l2: #7bc96f; --contribution-l3: #239a3b; --contribution-l4: #196127; - --font-chinese: 'Chiron GoRound TC', 'Ma Shan Zheng', serif; /* Match nav + card UI English (see app/components/nav.tsx) */ --font-ui-en: 'Nunito', ui-sans-serif, system-ui, sans-serif; /* Border hierarchy for mag-card */ --color-border-primary: #a1a1aa; /* zinc-400 — bottom accent */ --color-border-secondary: #d4d4d8; /* zinc-300 — sides */ --color-border-tertiary: #e4e4e7; /* zinc-200 — shadow */ + --mag-card-radius: 6px; } .dark { @@ -41,9 +40,6 @@ --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); - --font-serif: var(--font-lora); - --font-instrument-serif: var(--font-instrument-serif); - --font-dm-sans: var(--font-dm-sans); --font-jetbrains-mono: 'JetBrains Mono', monospace; } @@ -69,7 +65,7 @@ body { border-bottom: 3px solid var(--color-border-primary); border-right: 2px solid var(--color-border-secondary); box-shadow: 5px 5px 0px 0px var(--color-border-tertiary); - border-radius: 2px; + border-radius: var(--mag-card-radius); padding: 1.5rem; overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease; @@ -132,17 +128,6 @@ body { .slide-enter { animation: slide-in-right 250ms ease forwards; } /* ── Listening widget (home) ──────────────────────────────── */ -@keyframes listening-eq-wave { - 0%, - 100% { - transform: scaleY(0.28); - opacity: 0.5; - } - 50% { - transform: scaleY(1); - opacity: 1; - } -} @keyframes listening-live-pulse { 0%, 100% { @@ -152,12 +137,13 @@ body { box-shadow: 0 0 0 10px rgb(29 185 84 / 0); } } -@keyframes listening-sheen-shift { - 0% { - background-position: 0% 50%; - } +@keyframes listening-live-pulse-dark { + 0%, 100% { - background-position: 100% 50%; + box-shadow: 0 0 0 0 rgb(29 185 84 / 0.4); + } + 55% { + box-shadow: 0 0 0 10px rgb(29 185 84 / 0); } } @keyframes listening-soft-breathe { @@ -170,521 +156,12 @@ body { } } -.listening-eq-bar-active { - width: 3px; - height: 12px; - border-radius: 9999px; - transform-origin: center bottom; - transform: scaleY(0.35); - animation: listening-eq-wave 0.82s ease-in-out infinite; - will-change: transform; -} - -.listening-eq-bar-idle { - width: 3px; - border-radius: 9999px; - transform-origin: center bottom; - opacity: 0.38; -} - -.listening-eq-bar-idle:nth-child(1) { - height: 5px; -} -.listening-eq-bar-idle:nth-child(2) { - height: 9px; -} -.listening-eq-bar-idle:nth-child(3) { - height: 7px; -} -.listening-eq-bar-idle:nth-child(4) { - height: 11px; -} - -.listening-wave-shell { - position: relative; - width: 24px; - height: 12px; - border-radius: 9999px; - overflow: hidden; - background: rgb(212 212 216 / 0.45); - border: 1px solid rgb(212 212 216 / 0.6); -} -.dark .listening-wave-shell { - background: rgb(63 63 70 / 0.55); - border-color: rgb(82 82 91 / 0.65); -} -.listening-wave-orb { - position: absolute; - top: 2px; - left: 2px; - width: 6px; - height: 6px; - border-radius: 9999px; - animation: listening-liquid-flow var(--fx-speed, 820ms) ease-in-out infinite; - will-change: transform, opacity; -} -@keyframes listening-liquid-flow { - 0% { - transform: translateX(0px) scale(0.74); - opacity: 0.52; - } - 45% { - transform: translateX(calc(var(--wave-travel, 16px) * 0.5)) scale(var(--wave-scale-peak, 1.08)); - opacity: 0.98; - } - 100% { - transform: translateX(var(--wave-travel, 16px)) scale(0.72); - opacity: 0.5; - } -} - -.listening-fx-bars { - display: flex; - align-items: end; - gap: 2px; - width: 34px; - height: 14px; -} -.listening-fx-bar { - width: 4px; - border-radius: 9999px; - animation: listening-prism-bars var(--fx-speed, 820ms) ease-in-out infinite; - transform-origin: center bottom; -} -@keyframes listening-prism-bars { - 0%, - 100% { height: 4px; opacity: 0.45; } - 50% { height: calc(7px + var(--fx-intensity, 0.5) * 7px); opacity: 1; } -} - -.listening-fx-orbit { - position: relative; - width: 16px; - height: 16px; - border-radius: 9999px; - border: 1px solid rgb(161 161 170 / 0.45); -} -.dark .listening-fx-orbit { - border-color: rgb(82 82 91 / 0.65); -} -.listening-fx-comet { - position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 4px; - border-radius: 9999px; - transform-origin: -6px 0; - animation: listening-orbit-spin var(--fx-pulse, 2200ms) linear infinite; -} -@keyframes listening-orbit-spin { - from { transform: rotate(0deg) translateX(6px); opacity: 0.45; } - to { transform: rotate(360deg) translateX(6px); opacity: 1; } -} - -.listening-fx-radar { - position: relative; - width: 20px; - height: 20px; - border-radius: 9999px; -} -.listening-fx-radar::before, -.listening-fx-radar::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 9999px; - border: 1px solid rgb(161 161 170 / 0.45); - animation: listening-radar var(--fx-pulse, 2200ms) ease-out infinite; -} -.listening-fx-radar::after { - animation-delay: calc(var(--fx-pulse, 2200ms) * -0.5); -} -.dark .listening-fx-radar::before, -.dark .listening-fx-radar::after { - border-color: rgb(82 82 91 / 0.65); -} -.listening-fx-radar-dot { - position: absolute; - top: 50%; - left: 50%; - width: 4px; - height: 4px; - border-radius: 9999px; - transform: translate(-50%, -50%) scale(calc(0.85 + var(--fx-intensity, 0.5) * 0.45)); -} -@keyframes listening-radar { - from { transform: scale(0.35); opacity: 0.85; } - to { transform: scale(1); opacity: 0; } -} - -.listening-fx-ribbon { - position: relative; - width: 34px; - height: 12px; - overflow: hidden; -} -.listening-fx-ribbon-line { - position: absolute; - left: -24px; - top: 4px; - width: 58px; - height: 3px; - border-radius: 9999px; - filter: saturate(calc(0.8 + var(--fx-intensity, 0.5) * 0.4)); - animation: listening-ribbon-flow var(--fx-speed, 820ms) linear infinite; -} -@keyframes listening-ribbon-flow { - from { transform: translateX(0px) skewX(-16deg); opacity: 0.5; } - 50% { transform: translateX(14px) skewX(0deg); opacity: 1; } - to { transform: translateX(28px) skewX(16deg); opacity: 0.5; } -} - -.listening-fx-lattice { - position: relative; - width: 34px; - height: 12px; -} -.listening-fx-spark { - position: absolute; - width: 3px; - height: 3px; - border-radius: 9999px; - animation: listening-spark-lattice var(--fx-speed, 820ms) ease-in-out infinite; -} -.listening-fx-spark:nth-child(1) { left: 2px; top: 2px; } -.listening-fx-spark:nth-child(2) { left: 8px; top: 8px; } -.listening-fx-spark:nth-child(3) { left: 14px; top: 4px; } -.listening-fx-spark:nth-child(4) { left: 20px; top: 1px; } -.listening-fx-spark:nth-child(5) { left: 26px; top: 7px; } -.listening-fx-spark:nth-child(6) { left: 31px; top: 3px; } -@keyframes listening-spark-lattice { - 0%, 100% { transform: scale(0.55); opacity: 0.35; } - 45% { transform: scale(calc(0.8 + var(--fx-intensity, 0.5) * 0.8)); opacity: 1; } -} - -.listening-fx-strobe { - position: relative; - width: 18px; - height: 18px; - border-radius: 9999px; - border: 1px dashed rgb(161 161 170 / 0.55); - animation: listening-strobe-spin calc(var(--fx-pulse, 2200ms) * 1.2) linear infinite; -} -.dark .listening-fx-strobe { - border-color: rgb(82 82 91 / 0.65); -} -.listening-fx-strobe-core { - position: absolute; - top: 50%; - left: 50%; - width: 5px; - height: 5px; - border-radius: 9999px; - transform: translate(-50%, -50%) scale(calc(0.8 + var(--fx-phase, 0) * 0.5)); - opacity: calc(0.4 + var(--fx-phase, 0) * 0.6); -} -@keyframes listening-strobe-spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -/* New FX gallery (v2) */ -.listening-fx-crest { - display: flex; - align-items: end; - gap: 2px; - width: 38px; - height: 14px; -} -.listening-fx-crest-peak { - width: 3px; - height: 4px; - border-radius: 9999px; - transform-origin: center bottom; - animation: listening-crest-random var(--fx-speed, 820ms) ease-in-out infinite; -} -.listening-fx-crest-peak:nth-child(2n) { animation-duration: calc(var(--fx-speed, 820ms) * 1.2); } -.listening-fx-crest-peak:nth-child(3n) { animation-duration: calc(var(--fx-speed, 820ms) * 0.78); } -@keyframes listening-crest-random { - 0%, 100% { transform: scaleY(0.34); opacity: 0.38; } - 37% { transform: scaleY(calc(0.55 + var(--fx-intensity, 0.5) * 0.95)); opacity: 1; } - 64% { transform: scaleY(calc(0.25 + var(--fx-phase, 0) * 1.05)); opacity: 0.7; } -} - -.listening-fx-shards { - position: relative; - width: 84px; - height: 20px; - padding: 1px 0 2px; - overflow: hidden; -} -.listening-fx-shards::before { - content: ""; - position: absolute; - inset: 1px -16px; - background: linear-gradient( - 100deg, - transparent 0%, - rgb(255 255 255 / 0.18) 45%, - rgb(255 255 255 / 0.42) 50%, - rgb(255 255 255 / 0.18) 55%, - transparent 100% - ); - transform: translateX(-120%); - opacity: 0; - pointer-events: none; -} -.dark .listening-fx-shards::before { - background: linear-gradient( - 100deg, - transparent 0%, - rgb(217 168 112 / 0.08) 45%, - rgb(217 168 112 / 0.2) 50%, - rgb(217 168 112 / 0.08) 55%, - transparent 100% - ); -} -.listening-fx-shards-live { - animation: listening-shards-breath calc(var(--fx-pulse, 2200ms) * 0.95) ease-in-out infinite; -} -.listening-fx-shards-live::before { - opacity: 1; - animation: listening-shards-sweep calc(var(--fx-pulse, 2200ms) * 1.35) cubic-bezier(.35,.05,.25,1) infinite; -} -.listening-fx-shard { - position: absolute; - left: var(--shard-x, 0px); - bottom: 1px; - width: 5px; - height: var(--shard-height, 10px); - border-radius: 2px; - transform-origin: center bottom; - opacity: 0.5; - filter: saturate(calc(0.9 + var(--fx-intensity, 0.5) * 0.6)); - box-shadow: - 0 0 0 1px rgb(255 255 255 / 0.06) inset, - 0 0 6px rgb(29 185 84 / 0.22); - animation: - listening-shard-flick var(--fx-pulse, 2200ms) cubic-bezier(.17,.84,.44,1) infinite, - listening-shard-drift calc(var(--fx-speed, 820ms) * 2.2) ease-in-out infinite; -} -@keyframes listening-shard-flick { - 0%, 100% { - transform: translateY(2px) scaleY(0.62) rotate(calc(var(--shard-tilt, 8deg) * -0.45)); - opacity: 0.35; - } - 35% { - transform: translateY(0px) scaleY(calc(0.92 + var(--fx-intensity, 0.5) * 0.55)) rotate(calc(var(--shard-tilt, 8deg) * 0.35)); - opacity: 0.95; - } - 62% { - transform: translateY(-1px) scaleY(calc(0.85 + var(--fx-phase, 0) * 0.5)) rotate(var(--shard-tilt, 8deg)); - opacity: 0.75; - } -} -@keyframes listening-shard-drift { - 0%, 100% { margin-bottom: 0px; } - 50% { margin-bottom: calc(1px + var(--fx-intensity, 0.5) * 2px); } -} -@keyframes listening-shards-sweep { - 0%, 28% { - transform: translateX(-120%); - opacity: 0; - } - 44% { - opacity: calc(0.22 + var(--fx-intensity, 0.5) * 0.35); - } - 62% { - transform: translateX(120%); - opacity: 0; - } - 100% { - transform: translateX(120%); - opacity: 0; - } -} -@keyframes listening-shards-breath { - 0%, 100% { - transform: translateY(0px) scaleY(0.98); - } - 18% { - transform: translateY(-0.5px) scaleY(calc(1 + var(--fx-intensity, 0.5) * 0.06)); - } - 52% { - transform: translateY(0px) scaleY(0.995); - } -} - -.listening-fx-pendulum { - display: flex; - align-items: center; - gap: 4px; - width: 34px; - height: 14px; -} -.listening-fx-pendulum-dot { - width: 5px; - height: 5px; - border-radius: 9999px; - transform-origin: center -4px; - animation: listening-pendulum-sway var(--fx-pulse, 2200ms) ease-in-out infinite; -} -@keyframes listening-pendulum-sway { - 0%, 100% { transform: rotate(-20deg) translateY(1px); opacity: 0.5; } - 50% { transform: rotate(20deg) translateY(-1px); opacity: 1; } -} - -.listening-fx-drizzle { - position: relative; - width: 34px; - height: 14px; - overflow: hidden; -} -.listening-fx-drop { - position: absolute; - top: -1px; - width: 3px; - height: 3px; - border-radius: 1px; - animation: listening-drop-fall var(--fx-speed, 820ms) linear infinite; -} -.listening-fx-drop:nth-child(1){ left: 3px; } -.listening-fx-drop:nth-child(2){ left: 10px; } -.listening-fx-drop:nth-child(3){ left: 17px; } -.listening-fx-drop:nth-child(4){ left: 24px; } -.listening-fx-drop:nth-child(5){ left: 30px; } -@keyframes listening-drop-fall { - from { transform: translateY(-2px) scale(0.85); opacity: 0.2; } - 30% { opacity: 0.95; } - to { transform: translateY(13px) scale(calc(0.65 + var(--fx-intensity, 0.5) * 0.4)); opacity: 0.15; } -} - -.listening-fx-moire { - position: relative; - width: 34px; - height: 12px; - overflow: hidden; -} -.listening-fx-moire-line { - position: absolute; - inset: 4px -8px; - height: 2px; - border-radius: 9999px; - opacity: 0.65; - animation: listening-moire-shift var(--fx-speed, 820ms) ease-in-out infinite; -} -.listening-fx-moire-line:last-child { - animation-delay: calc(var(--fx-speed, 820ms) * -0.5); - opacity: 0.45; -} -@keyframes listening-moire-shift { - 0%,100% { transform: translateX(-4px) skewX(-22deg); } - 50% { transform: translateX(10px) skewX(20deg); } -} - -.listening-fx-braid { - position: relative; - width: 34px; - height: 12px; -} -.listening-fx-braid::before { - content: ""; - position: absolute; - left: 1px; - right: 1px; - top: 5px; - height: 1px; - background: rgb(161 161 170 / 0.35); -} -.dark .listening-fx-braid::before { - background: rgb(82 82 91 / 0.5); -} -.listening-fx-braid-node { - position: absolute; - top: 4px; - width: 4px; - height: 4px; - border-radius: 9999px; - animation: listening-braid-phase calc(var(--fx-pulse, 2200ms) * 0.9) ease-in-out infinite; -} -.listening-fx-braid-node:nth-child(1) { left: 4px; } -.listening-fx-braid-node:nth-child(2) { left: 15px; } -.listening-fx-braid-node:nth-child(3) { left: 26px; } -@keyframes listening-braid-phase { - 0%,100% { transform: translateY(0px) scale(0.7); opacity: 0.4; } - 50% { transform: translateY(calc(-3px - var(--fx-phase, 0) * 2px)) scale(1.05); opacity: 1; } -} - -.listening-fx-glyph { - position: relative; - width: 20px; - height: 20px; -} -.listening-fx-glyph::before, -.listening-fx-glyph::after { - content: ""; - position: absolute; - inset: 1px; - border-radius: 9999px; - border: 1px solid rgb(161 161 170 / 0.45); - animation: listening-glyph-ripple var(--fx-pulse, 2200ms) ease-out infinite; -} -.listening-fx-glyph::after { - animation-delay: calc(var(--fx-pulse, 2200ms) * -0.45); -} -.dark .listening-fx-glyph::before, -.dark .listening-fx-glyph::after { - border-color: rgb(82 82 91 / 0.65); -} -.listening-fx-glyph-core { - position: absolute; - top: 50%; - left: 50%; - width: 5px; - height: 5px; - border-radius: 9999px; - transform: translate(-50%, -50%); -} -@keyframes listening-glyph-ripple { - from { transform: scale(0.45); opacity: 0.8; } - to { transform: scale(calc(0.85 + var(--fx-intensity, 0.5) * 0.35)); opacity: 0; } -} - .listening-live-dot { animation: listening-live-pulse 2.2s ease-out infinite; } .dark .listening-live-dot { animation-name: listening-live-pulse-dark; } -@keyframes listening-live-pulse-dark { - 0%, - 100% { - box-shadow: 0 0 0 0 rgb(29 185 84 / 0.4); - } - 55% { - box-shadow: 0 0 0 10px rgb(29 185 84 / 0); - } -} - -.listening-card-glow { - animation: listening-sheen-shift 14s linear infinite alternate; - background-image: linear-gradient( - 120deg, - rgb(249 246 243 / 0.55) 0%, - rgb(255 255 255 / 0.2) 40%, - rgb(237 229 217 / 0.35) 100% - ); - background-size: 240% 100%; -} -.dark .listening-card-glow { - background-image: linear-gradient( - 125deg, - rgb(52 42 34 / 0.35) 0%, - rgb(37 32 24 / 0.15) 45%, - rgb(58 48 39 / 0.4) 100% - ); -} .listening-art-live { animation: listening-soft-breathe 6s ease-in-out infinite; @@ -739,12 +216,6 @@ body { } -/* ── Nav wave underline (portal in nav.tsx) ── */ -@keyframes nav-wave-flow { - from { background-position-x: 0px; } - to { background-position-x: 20px; } -} - /* ── iMessage-style letter wave (used by jump-text.tsx) ─────── */ @keyframes letter-jump { 0%, 14%, 100% { transform: translateY(0); } @@ -755,49 +226,6 @@ body { animation: letter-jump 3.6s cubic-bezier(0.34, 1.56, 0.64, 1) infinite; } -/* ── ":D" giggle — grows and wiggles right after the wave ───── */ -@keyframes emote-giggle { - 0%, 18%, 42%, 100% { transform: scale(1) rotate(0deg); } - 24% { transform: scale(1.32) rotate(5deg); } - 30% { transform: scale(1.12) rotate(-4deg); } - 36% { transform: scale(1.24) rotate(3deg); } -} -.emote-giggle { - display: inline-block; - transform-origin: 55% 65%; - animation: emote-giggle 3.6s ease-in-out infinite; -} - -@media (prefers-reduced-motion: reduce) { - .jump-letter, - .emote-giggle { animation: none; } -} - -/* ── Highlight.js syntax theme (notes section) ──────────────── */ -.hljs { color: #24292e; background: transparent; } -.hljs-keyword, .hljs-operator { color: #d73a49; font-weight: bold; } -.hljs-string, .hljs-doctag { color: #032f62; } -.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; } -.hljs-title, .hljs-section { color: #6f42c1; font-weight: bold; } -.hljs-variable, .hljs-template-variable, .hljs-attr { color: #005cc5; } -.hljs-number, .hljs-literal { color: #005cc5; } -.hljs-built_in, .hljs-builtin-name { color: #e36209; } -.hljs-type, .hljs-class .hljs-title { color: #6f42c1; } -.hljs-params { color: #24292e; } -.hljs-meta { color: #735c0f; } - -.dark .hljs { color: #e6edf3; } -.dark .hljs-keyword, .dark .hljs-operator { color: #ff7b72; } -.dark .hljs-string, .dark .hljs-doctag { color: #a5d6ff; } -.dark .hljs-comment, .dark .hljs-quote { color: #8b949e; } -.dark .hljs-title, .dark .hljs-section { color: #d2a8ff; font-weight: bold; } -.dark .hljs-variable, .dark .hljs-template-variable, .dark .hljs-attr { color: #79c0ff; } -.dark .hljs-number, .dark .hljs-literal { color: #79c0ff; } -.dark .hljs-built_in, .dark .hljs-builtin-name { color: #ffa657; } -.dark .hljs-type, .dark .hljs-class .hljs-title { color: #d2a8ff; } -.dark .hljs-params { color: #e6edf3; } -.dark .hljs-meta { color: #e3b341; } - @media (prefers-reduced-motion: reduce) { .subpage-enter::before { animation: none; @@ -808,41 +236,8 @@ body { opacity: 1; transform: none; } - .listening-eq-bar-active { - animation: none; - transform: scaleY(0.72); - opacity: 1; - } - .listening-live-dot { - animation: none !important; - } - .listening-wave-orb { - animation: none; - transform: translateX(8px) scale(0.85); - opacity: 0.85; - } - .listening-fx-bar, - .listening-fx-comet, - .listening-fx-radar::before, - .listening-fx-radar::after, - .listening-fx-ribbon-line, - .listening-fx-spark, - .listening-fx-strobe, - .listening-fx-crest-peak, - .listening-fx-shard, - .listening-fx-pendulum-dot, - .listening-fx-drop, - .listening-fx-moire-line, - .listening-fx-braid-node, - .listening-fx-glyph::before, - .listening-fx-glyph::after { - animation: none !important; - } - .listening-card-glow { - animation: none; - background-position: 50% 50%; - background-size: 100% 100%; - } + .jump-letter, + .listening-live-dot, .listening-art-live { animation: none !important; } diff --git a/app/layout.tsx b/app/layout.tsx index 794f2d0..35dca79 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; import "./globals.css"; import Nav from "./components/nav"; +import SiteFooter from "./components/site-footer"; import SubpageEnter from "./components/subpage-enter"; import Providers from "./components/providers"; import { Analytics } from "@vercel/analytics/react"; @@ -19,10 +20,10 @@ import Script from "next/script"; export const metadata: Metadata = { metadataBase: new URL("https://kaichen.dev"), title: "Kai T. Chen", - description: "Visiting UC Berkeley · Maths at SUSTech", + description: "Visiting at UC Berkeley · Maths at SUSTech", openGraph: { title: "Kai T. Chen", - description: "Visiting UC Berkeley · Maths at SUSTech", + description: "Visiting at UC Berkeley · Maths at SUSTech", url: "https://kaichen.dev", siteName: "Kai T. Chen", images: [{ url: "/opengraph-image", width: 1200, height: 630 }], @@ -32,7 +33,7 @@ export const metadata: Metadata = { twitter: { card: "summary_large_image", title: "Kai T. Chen", - description: "Visiting UC Berkeley · Maths at SUSTech", + description: "Visiting at UC Berkeley · Maths at SUSTech", images: ["/opengraph-image"], }, }; @@ -62,6 +63,7 @@ export default function RootLayout({
{children}
+ diff --git a/app/lib/substack.ts b/app/lib/substack.ts deleted file mode 100644 index 4398f54..0000000 --- a/app/lib/substack.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type SubstackPost = { - title: string; - url: string; - pubDate: string; - description: string; -}; - -// `https://substack.com/@/feed` started returning 404 (it serves the -// generic discover HTML page now), so we hit the publication feed directly. -const FEEDS = ["https://kaiiiichen.substack.com/feed"]; - -// Refetch the RSS feed every 10 minutes so newly-published posts appear on the -// homepage within ~10 min instead of the previous 1 h window. -const REVALIDATE_SECONDS = 600; - -function extractTag(xml: string, tag: string): string { - const cdataMatch = new RegExp(`<${tag}[^>]*><\\/${tag}>`, "i").exec(xml); - if (cdataMatch) return cdataMatch[1].trim(); - const plainMatch = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml); - return plainMatch ? plainMatch[1].trim() : ""; -} - -/** Pure RSS XML → posts (first 3 items). Used by `getSubstackPosts` and tests. */ -export function parseSubstackRssXml(xml: string): SubstackPost[] { - const itemBlocks = xml.match(/][\s\S]*?<\/item>/gi) ?? []; - return itemBlocks.slice(0, 3).map((block) => { - const title = extractTag(block, "title"); - const url = extractTag(block, "link") || extractTag(block, "guid"); - const pubDate = extractTag(block, "pubDate"); - const rawDesc = extractTag(block, "description").replace(/<[^>]+>/g, ""); - const description = rawDesc.slice(0, 100) + (rawDesc.length > 100 ? "…" : ""); - return { title, url, pubDate, description }; - }); -} - -export async function getSubstackPosts(): Promise { - for (const feed of FEEDS) { - try { - const res = await fetch(feed, { - next: { revalidate: REVALIDATE_SECONDS }, - headers: { "User-Agent": "kaichen.dev/1.0" }, - }); - if (!res.ok) continue; - const xml = await res.text(); - const posts = parseSubstackRssXml(xml); - if (posts.length > 0) return posts; - } catch { - // try next feed - } - } - return []; -} diff --git a/app/misc/page.tsx b/app/misc/page.tsx index 015cfef..328892d 100644 --- a/app/misc/page.tsx +++ b/app/misc/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "Misc — Kai T. Chen", - description: "Curated lists: news worth attention, tools and creators I love, remembrance.", + description: "Curated lists: watching, tools and creators I love, remembrance.", }; const THING_GROUPS = [ @@ -109,10 +109,10 @@ export default function Misc() {

- {/* Call for Attention — full width */} + {/* Watching — full width */}
-
News
+
Watching
{CALL_FOR_ATTENTION.map(({ title, href, source, date }) => (
- {/* Two-column: Tools (left) | Remembrance (right) */} -
+ {/* Remembrance — full width */} + - {/* Things I Love */} + {/* Things I Love — full width */} +
Things I Love
@@ -188,41 +222,6 @@ export default function Misc() { ))}
- - {/* Remembrance */} -
-
Remembrance
-
- {REMEMBRANCE.map(({ name, href, note }) => ( - -
- - ↗ - -

- {name} -

-
-

- {note} -

-
- ))} -
-
-
diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx index e893b6e..e73fce4 100644 --- a/app/opengraph-image.tsx +++ b/app/opengraph-image.tsx @@ -4,5 +4,5 @@ export const size = OG_SIZE; export const contentType = OG_CONTENT_TYPE; export default function OGImage() { - return makeOGImage("Kai T. Chen", "Visiting UC Berkeley · Maths at SUSTech"); + return makeOGImage("Kai T. Chen", "Visiting at UC Berkeley · Maths at SUSTech"); } diff --git a/app/page.tsx b/app/page.tsx index bacfcd1..98ab440 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,9 +2,10 @@ import AvatarCard from "./components/avatar-card"; import JumpText from "./components/jump-text"; import ListeningCard from "./components/listening-card"; import WeatherCard from "./components/weather-card"; -import ProjectStars from "./components/project-stars"; +import PinnedProjectLink from "./components/pinned-project-link"; +import HomeSocialLinks from "./components/home-social-links"; +import GwwcBadge from "./components/gwwc-badge"; import { getPinnedProjects } from "./lib/github-pinned"; -import { getSubstackPosts } from "./lib/substack"; const PERSON_JSON_LD = { "@context": "https://schema.org", @@ -33,7 +34,7 @@ const PERSON_JSON_LD = { } as const; export default async function Home() { - const [substackPosts, projects] = await Promise.all([getSubstackPosts(), getPinnedProjects()]); + const projects = await getPinnedProjects(); return (