diff --git a/.env.example b/.env.example index c62a04c..373390e 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # ============================================================================= # --- Supabase ---------------------------------------------------------------- -# Only used by /api/lastfm/now-playing for optional listening history. +# Used by `/api/spotify/now-playing` for optional listening history. # NEXT_PUBLIC_SUPABASE_URL is paired with the service role key for server-side # reads/writes against `listening_history` / `listening_stats`. NEXT_PUBLIC_SUPABASE_URL= @@ -12,10 +12,13 @@ NEXT_PUBLIC_SUPABASE_URL= # Server-only: bypasses Row Level Security — NEVER expose to the client. SUPABASE_SERVICE_ROLE_KEY= -# --- Last.fm ----------------------------------------------------------------- -# API key from https://www.last.fm/api/account/create -# If unset, /api/lastfm/now-playing still responds with graceful fallbacks. -LASTFM_API_KEY= +# --- Spotify ------------------------------------------------------------------ +# Spotify app + refresh token (scopes: user-read-currently-playing, +# user-read-recently-played). If unset, the now-playing API falls back to +# in-memory + Supabase `listening_stats`. +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= +SPOTIFY_REFRESH_TOKEN= # --- GitHub ------------------------------------------------------------------ # Personal access token or fine-grained token with repo read access. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a04452a..ee9b6b9 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -35,4 +35,5 @@ - [ ] I have read [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). - [ ] No secrets, `.env.local`, or production tokens are included in this PR. - [ ] **[README.md](../README.md)** / **[`.env.example`](../.env.example)** updated if routes, APIs, user-facing behavior, or env vars changed. +- [ ] **[CLAUDE.md](../CLAUDE.md)** updated if architecture, main data flows, or important paths changed (keeps AI / editor context aligned with README). - [ ] **[CONTRIBUTING.md](../CONTRIBUTING.md)** / **[AGENTS.md](../AGENTS.md)** updated if contribution or agent workflow rules changed. diff --git a/CLAUDE.md b/CLAUDE.md index 2386c23..acaec57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,10 +16,12 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C | Concern | Implementation | | --- | --- | -| **Now playing** | `GET /api/lastfm/now-playing` — Last.fm + optional iTunes art; optional Supabase writes (`listening_*`) with service role | -| **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s**; `Cache-Control` on API allows short CDN cache | -| **GitHub** | `GET /api/github/contributions` — GraphQL calendar + REST; `GET /api/github/stars` — star counts | -| **Weather** | `GET /api/weather` — Open-Meteo, fixed coordinates (Berkeley), revalidated fetch | +| **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 + REST; `GET /api/github/stars` — star counts; pinned repos + static fallback in `app/lib/github-pinned.ts` | +| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley); current temp, **feels-like**, **humidity**, condition, 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` | +| **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 | @@ -38,15 +40,19 @@ npm run lint && npm run typecheck && npm run test && npm run build ## Important paths -- [`lib/now-playing.ts`](lib/now-playing.ts) — types for Last.fm payload -- [`lib/lastfm-now-playing-helpers.ts`](lib/lastfm-now-playing-helpers.ts) — pure helpers used by the now-playing route (tested) +- [`lib/now-playing.ts`](lib/now-playing.ts) — types shared by `/api/spotify/now-playing` + `useNowPlaying` +- [`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) -- [`next.config.ts`](next.config.ts) — webpack config, `withSentryConfig` +- [`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) — webpack + MDX loaders, `withSentryConfig` --- ## Conventions -- **Dark mode** — support `dark:` for new UI. +- **Dark mode** — support `dark:` for new UI (including social icon hover colors where relevant). - **Secrets** — never commit `.env.local` or tokens; see AGENTS.md and README. - **User prompts** — when the user sends copy-paste blocks, keep them as single fenced blocks when echoing back. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5992c90..5741561 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,8 +39,8 @@ cp .env.example .env.local Edit `.env.local` as needed. You **do not** need every integration to run the app locally: -- Missing **Last.fm** / **GitHub** keys usually degrade specific widgets. -- **Supabase** placeholders are injected in CI for `next build`; locally, real or dummy public keys may be required for pages that call `createBrowserClient` at import time — see [README — CI placeholders](README.md#ci-placeholders). +- Missing **Spotify** / **GitHub** keys usually degrade specific widgets. +- **Supabase** is optional for `/api/spotify/now-playing` only; `next build` succeeds without Supabase env (see [README — Environment variables](README.md#environment-variables), subsection **CI builds without live Supabase**). **Never commit** `.env.local`, `.env.vercel.check`, tokens, or `SENTRY_AUTH_TOKEN`. @@ -92,8 +92,9 @@ npm run build ### Stack and rendering -- **Next.js 16** App Router, **React 19**, **TypeScript**, **Tailwind CSS 4**. +- **Next.js 16** App Router, **React 19**, **TypeScript**, **Tailwind CSS 4**, **Vitest 4** for unit tests. - **MDX** uses the **Webpack** loader configured in [`next.config.ts`](next.config.ts). Local `dev` and `build` scripts use **`--webpack`**. Do not rely on Turbopack-only behavior for `.mdx` files. +- **Fonts:** Geist Sans/Mono on `` / default `body`; **Nunito** for nav and magazine-style cards (see [`app/globals.css`](app/globals.css), [`app/layout.tsx`](app/layout.tsx)). ### Style @@ -114,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 (Last.fm user, 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, Substack, etc.). --- diff --git a/README.md b/README.md index f74dae5..5dcd923 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,15 @@ This repository is a **[Next.js 16](https://nextjs.org/)** application using the The site combines: -- A **marketing-style home page** (identity, listening status, weather, **GitHub pinned repositories** via GraphQL with a static fallback, and Substack headlines). -- **Dynamic data** from Last.fm, GitHub, Open-Meteo, and optional Supabase-backed listening history. +- A **marketing-style home page** (identity, **social icons** with brand-colored hovers, Spotify listening card, Berkeley weather, **GitHub pinned repositories** via GraphQL with a static fallback, and Substack headlines). +- **Dynamic data** from Spotify, GitHub, Open-Meteo, and optional Supabase-backed listening history. - **Notes** are hosted externally on Notion (via the top navigation link). - **Optional observability** via Sentry (client, server, edge) and Vercel Analytics / Speed Insights. +**UI / typography:** The root `body` uses **Geist Sans** (`font-sans`). **Nunito** (via `@fontsource/nunito`) is used for the top nav, **magazine-style cards** (`.mag-card` / `.mag-label`), and most English copy inside those surfaces so the chrome stays consistent. Theme defaults to **light** when unset; `.dark` on `` comes from the theme script + provider. + +**Now playing:** The client hook [`app/hooks/use-now-playing.ts`](app/hooks/use-now-playing.ts) polls **`GET /api/spotify/now-playing`** about every **10s** (`cache: "no-store"`). + There is **no** `middleware.ts` (or `proxy.ts`) in this repo — every route is publicly accessible and rendered by the App Router directly. --- @@ -158,13 +162,15 @@ kaichen.dev/ │ ├── opengraph-image.tsx # OG image for / │ ├── about/ # Bio / CV-style page + OG │ ├── projects/ # Projects + GitHub heatmap + OG -│ ├── api/ # Route handlers (Last.fm, GitHub, weather) -│ ├── components/ # UI: nav, cards, theme, weather, listening, GitHub heatmap, … -│ ├── hooks/ # e.g. use-now-playing.ts +│ ├── api/ # Route handlers (Spotify, GitHub, weather) +│ ├── components/ # UI: nav, mobile-nav, cards, theme, weather, listening, GitHub heatmap, … +│ ├── hooks/ # use-now-playing.ts (Spotify poll) │ └── lib/ # og.tsx, substack RSS, GitHub pinned repos (GraphQL) ├── lib/ # Shared server-oriented helpers + Vitest tests -│ ├── now-playing.ts # Types for Last.fm payload -│ ├── lastfm-now-playing-helpers.ts +│ ├── now-playing.ts # Types for now-playing payload +│ ├── spotify-now-playing-helpers.ts +│ ├── spotify-access-token.ts +│ ├── listening-supabase.ts │ ├── weather-open-meteo.ts │ └── *.test.ts ├── mdx-components.tsx # MDX element mapping @@ -197,12 +203,12 @@ kaichen.dev/ | Layer | Choices | | --- | --- | | Framework | Next.js **16.2** (App Router), React **19**, TypeScript **5** | -| Styling | Tailwind CSS **4** (`@tailwindcss/postcss`), custom CSS in `app/globals.css` | -| Content | Markdown/MDX pipeline available in config for future content routes | -| Fonts | `@fontsource/*` (Nunito, Bitter, JetBrains Mono), `geist` (sans/mono CSS variables) | -| Data | Supabase (`@supabase/supabase-js`) — optional listening history DB writes (service role) for `/api/lastfm/now-playing` | +| Styling | Tailwind CSS **4** (`@tailwindcss/postcss`), shared UI tokens + **magazine cards** in [`app/globals.css`](app/globals.css) (`.mag-card`, `.mag-label`) | +| Content | **MDX / Markdown** page extensions are enabled in [`next.config.ts`](next.config.ts) via `@mdx-js/loader` (+ `remark-gfm`, `remark-math`, `rehype-katex`, `rehype-highlight`); mapping in root [`mdx-components.tsx`](mdx-components.tsx). There are no `app/**/*.mdx` routes in-tree yet—add files when you want long-form pages. | +| Fonts | `@fontsource/*` (**Nunito**, JetBrains Mono), **`geist`** (Geist Sans / Mono as CSS variables on ``, default `font-sans` on `
`) | +| Data | Supabase (`@supabase/supabase-js`) — optional listening history DB writes (service role) for `/api/spotify/now-playing` | | Monitoring | `@sentry/nextjs` (optional DSN), Vercel Analytics + Speed Insights | -| Testing | Vitest **3** | +| Testing | Vitest **4** | Pinned versions are in [`package.json`](package.json). @@ -212,7 +218,7 @@ Pinned versions are in [`package.json`](package.json). | Route | What it does | | --- | --- | -| `/` | Identity block, Last.fm line + card, Berkeley weather, **pinned GitHub repos** (GraphQL + fallback list), Substack RSS snippets | +| `/` | Identity block, social links (mailto + GitHub, LinkedIn, X, Spotify), **Listening** + **Location** cards (Spotify + Open‑Meteo: temp, condition, **feels-like**, **humidity**, local **America/Los_Angeles** clock via `berkeley-time.tsx`), **pinned GitHub repos** (GraphQL + fallback list), Substack RSS snippets | | `/about` | Education, experience, courses, volunteering | | `/projects` | Project cards + **GitHub contribution calendar** (client component, data from `/api/github/contributions`) | @@ -228,10 +234,10 @@ All handlers live under `app/api/`. | Method & path | Behavior | Caching / notes | | --- | --- | --- | -| `GET /api/lastfm/now-playing` | Last.fm `user.getrecenttracks`; optional iTunes artwork fallback; optional **service-role** writes to `listening_history` / `listening_stats` when a track is “now playing” | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`; uses `LASTFM_API_KEY`; in-memory `lastKnownTrack` fallback | +| `GET /api/spotify/now-playing` | Spotify `me/player/currently-playing` + `recently-played`; optional **service-role** merges legacy `listening_stats` rows into `spotify:Mathematics major (Fields Medal Honors Program) at Southern University of Science and @@ -75,20 +75,20 @@ export default function About() { {years}
{institution}
{role}
{sub && ({sub} @@ -158,19 +158,19 @@ export default function About() { )}
{role}
{org}
{desc} @@ -209,20 +209,20 @@ export default function About() { {years}
{role}
{org}
{desc && ({desc} @@ -257,7 +257,7 @@ export default function About() {
{code}
-{name}
+{name}
- {displayItem.title} -
-- {displayItem.artist} -
-- Nothing playing right now. -
- )} +function getWaveTuning(intensity?: number) { + const i = typeof intensity === "number" ? Math.max(0, Math.min(1, intensity)) : 0.5; + return { + waveTravelPx: 10 + i * 10, // 10 -> 20 + waveScalePeak: 0.9 + i * 0.45, // 0.9 -> 1.35 + shellOpacity: 0.72 + i * 0.28, + dotScale: 0.95 + i * 0.35, // 0.95 -> 1.3 + }; +} -+ {displayItem.title} +
++ {displayItem.artist} +
+Berkeley · CA · US
+ {w ? w.condition : "—"} +
+- {w ? w.condition : "—"} -
+ {/* Right: weather illustration */} +Visiting UC Berkeley 2026
-Maths at SUSTech · Fields Medal Honors Program
+Visiting @ UC Berkeley 2026
+Maths @ SUSTech ’27 · Fields Medal Honors Program
Welcome to my personal space at a corner of human made internet :D
+Welcome to my personal space at a corner of the human made internet :D
I am a believer of Longtermism and Effective Altruism.
-Currently, I'm also investigating faith and religion.
-And I'm still trying to build a consistant lifestyle lol.
+Currently, I'm also investigating AI alignment and ML fairness.
+And I'm still trying to build a consistant lifestyle & fitness routine lol.
I study Maths and I'm always awed by the beauty of deep abstract structures! I love the outline of analysis proofs and I'm especially obsessed with algebra.
-I really love building things. I dream of building something that can help people / make people happy with a fantastic user experience!
-Currently reading What's Your Dream?: Find Your Passion. Love Your Work. Build a Richer Life. and trying to ask myself questions in the book to question myself.
+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 really love tinkering stuff. I dream of building something that can help people / make people happy with a fantastic user experience!
+Currently reading What's Your Dream?: Find Your Passion. Love Your Work. Build a Richer Life. and actually sitting with the questions in it.
Pin repositories on your GitHub profile to show them here. @@ -165,7 +176,7 @@ export default async function Home() { ↗
{name} @@ -174,7 +185,7 @@ export default async function Home() {
{desc} @@ -203,7 +214,7 @@ export default async function Home() {
+
No posts yet.
) : ( @@ -222,7 +233,7 @@ export default async function Home() { ↗ {post.title} diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 32d323a..8276781 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -17,7 +17,7 @@ export default async function Projects() { ProjectsThings I've built, maintained, or archived with love. @@ -29,7 +29,7 @@ export default async function Projects() {
Pin repositories on your GitHub profile to show them here. @@ -52,7 +52,7 @@ export default async function Projects() { ↗
{name} @@ -63,7 +63,7 @@ export default async function Projects() { {/* Description */} {desc ? (
{desc}
diff --git a/lib/lastfm-now-playing-helpers.test.ts b/lib/lastfm-now-playing-helpers.test.ts
deleted file mode 100644
index c9dc783..0000000
--- a/lib/lastfm-now-playing-helpers.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { describe, expect, it } from "vitest";
-import {
- isLastFmTrackPlaying,
- pickAlbumArtFromLastFmImages,
-} from "@/lib/lastfm-now-playing-helpers";
-
-describe("lastfm now-playing helpers", () => {
- it("treats explicit now playing or a scrobble within 5 minutes as playing", () => {
- const now = new Date("2026-01-01T12:00:00.000Z");
- expect(isLastFmTrackPlaying({ "@attr": { nowplaying: "true" } }, now)).toBe(true);
- const uts = String(Math.floor(now.getTime() / 1000) - 60);
- expect(isLastFmTrackPlaying({ date: { uts } }, now)).toBe(true);
- });
-
- it("picks best image size and strips Last.fm placeholder art", () => {
- const hash = "2a96cbd8b46e442fc41c2b86b821562f";
- expect(
- pickAlbumArtFromLastFmImages([
- { size: "medium", "#text": "https://ok/m.jpg" },
- { size: "extralarge", "#text": `https://lastfm.freetls.fastly.net/i/u/300x300/${hash}` },
- ])
- ).toBe("");
- expect(
- pickAlbumArtFromLastFmImages([
- { size: "small", "#text": "https://x/s.jpg" },
- { size: "large", "#text": "https://x/l.jpg" },
- ])
- ).toBe("https://x/l.jpg");
- });
-});
diff --git a/lib/lastfm-now-playing-helpers.ts b/lib/lastfm-now-playing-helpers.ts
deleted file mode 100644
index 3844904..0000000
--- a/lib/lastfm-now-playing-helpers.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export type LastFmImageEntry = { size: string; "#text": string };
-
-export type LastFmTrackPlayingInput = {
- "@attr"?: { nowplaying?: string };
- date?: { uts?: string };
-};
-
-const PLACEHOLDER_HASH = "2a96cbd8b46e442fc41c2b86b821562f";
-
-const PLAYING_WINDOW_MS = 5 * 60 * 1000;
-
-/** Last.fm "now playing" OR scrobbled within the last 5 minutes (same window as route). */
-export function isLastFmTrackPlaying(track: LastFmTrackPlayingInput, now: Date): boolean {
- const isNowPlaying = track["@attr"]?.nowplaying === "true";
- const lastScrobbleTime = track.date?.uts ? parseInt(track.date.uts, 10) * 1000 : 0;
- const isRecentlyPlayed = lastScrobbleTime > now.getTime() - PLAYING_WINDOW_MS;
- return isNowPlaying || isRecentlyPlayed;
-}
-
-/** Picks best Last.fm image size and strips known placeholder art URL. */
-export function pickAlbumArtFromLastFmImages(images: LastFmImageEntry[] | undefined): string {
- let albumArt =
- images?.find((i) => i.size === "extralarge")?.["#text"] ||
- images?.find((i) => i.size === "large")?.["#text"] ||
- images?.find((i) => i.size === "medium")?.["#text"] ||
- "";
- if (albumArt.includes(PLACEHOLDER_HASH)) albumArt = "";
- return albumArt;
-}
diff --git a/lib/listening-supabase.ts b/lib/listening-supabase.ts
new file mode 100644
index 0000000..ecdb381
--- /dev/null
+++ b/lib/listening-supabase.ts
@@ -0,0 +1,83 @@
+import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+
+export function createListeningSupabase(): SupabaseClient | null {
+ const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!url || !key) return null;
+ return createClient(url, key);
+}
+
+export async function getLastPlayedListeningRow(db: SupabaseClient | null | undefined) {
+ if (!db) return undefined;
+ try {
+ const { data } = await db
+ .from("listening_stats")
+ .select("track_name, artist_name, album_art, song_url")
+ .order("last_played_at", { ascending: false })
+ .limit(1)
+ .single();
+ if (!data) return undefined;
+ return {
+ title: data.track_name,
+ artist: data.artist_name,
+ albumArt: data.album_art,
+ songUrl: data.song_url,
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+type ListeningStatsUpsertPayload = {
+ track_id: string;
+ track_name: string;
+ artist_name: string;
+ album_id: string;
+ album_name: string;
+ album_art: string;
+ song_url: string;
+};
+
+/**
+ * Collapses duplicate `listening_stats` rows that share the same track + artist listing
+ * (legacy rows from before Spotify) into one keyed by `spotify: