From e4852c3fc004b08cd21ab010b7bfd1ed97791f9d Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Thu, 7 May 2026 22:39:23 -0700 Subject: [PATCH 1/3] feat: refresh homepage identity UI and docs consistency Polish social/profile presentation, card typography behavior, and weather/listening details while updating repository docs to match the current Spotify-based architecture and contribution workflow. Co-authored-by: Cursor Co-authored-by: Claude --- .env.example | 13 +- .github/pull_request_template.md | 1 + CLAUDE.md | 22 +- CONTRIBUTING.md | 9 +- README.md | 66 +-- SECURITY.md | 4 +- app/about/page.tsx | 22 +- app/api/lastfm/now-playing/route.ts | 174 ------- app/api/spotify/now-playing/route.ts | 314 ++++++++++++ app/api/weather/route.ts | 2 +- app/components/listening-card.tsx | 214 ++++++--- app/components/weather-card.tsx | 69 +-- app/globals.css | 607 +++++++++++++++++++++++- app/hooks/use-now-playing.ts | 2 +- app/layout.tsx | 4 +- app/page.tsx | 63 ++- app/projects/page.tsx | 8 +- lib/lastfm-now-playing-helpers.test.ts | 30 -- lib/lastfm-now-playing-helpers.ts | 29 -- lib/listening-supabase.ts | 83 ++++ lib/now-playing.ts | 2 + lib/spotify-access-token.ts | 61 +++ lib/spotify-now-playing-helpers.test.ts | 21 + lib/spotify-now-playing-helpers.ts | 25 + lib/weather-open-meteo.test.ts | 9 +- lib/weather-open-meteo.ts | 13 +- next.config.ts | 1 - package-lock.json | 10 - package.json | 1 - 29 files changed, 1447 insertions(+), 432 deletions(-) delete mode 100644 app/api/lastfm/now-playing/route.ts create mode 100644 app/api/spotify/now-playing/route.ts delete mode 100644 lib/lastfm-now-playing-helpers.test.ts delete mode 100644 lib/lastfm-now-playing-helpers.ts create mode 100644 lib/listening-supabase.ts create mode 100644 lib/spotify-access-token.ts create mode 100644 lib/spotify-now-playing-helpers.test.ts create mode 100644 lib/spotify-now-playing-helpers.ts 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:`; writes `listening_*` while `is_playing` | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`; `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, `SPOTIFY_REFRESH_TOKEN`; in-memory `lastKnownTrack` fallback | | `GET /api/github/contributions` | GraphQL contribution calendar + REST search for latest commit + REST repo metadata for star counts | `dynamic = force-dynamic`; `Cache-Control: no-store`; requires `GITHUB_TOKEN` | | `GET /api/github/stars?repo=owner/name` | Returns `stargazers_count` and `archived` for a repo | `revalidate = 3600`; optional `GITHUB_TOKEN` for rate limits | -| `GET /api/weather` | Open-Meteo forecast for fixed Berkeley coordinates | `fetch` with `next.revalidate = 600` | +| `GET /api/weather` | Open-Meteo **current** conditions for fixed Berkeley coordinates (`temperature_2m`, `weathercode`, `apparent_temperature`, `relative_humidity_2m`, hourly rain chance) | `fetch` with `next.revalidate = 600`; parsed in [`lib/weather-open-meteo.ts`](lib/weather-open-meteo.ts) | --- @@ -243,9 +249,9 @@ Copy [`.env.example`](.env.example) to `.env.local`. **Never commit** real secre | Variable | Role | | --- | --- | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL. Paired with `SUPABASE_SERVICE_ROLE_KEY` inside `/api/lastfm/now-playing` for the optional listening history. | -| `SUPABASE_SERVICE_ROLE_KEY` | **Server-only.** Used by `/api/lastfm/now-playing` for DB writes/reads against `listening_history` / `listening_stats` — keep off the client bundle. | -| `LASTFM_API_KEY` | Last.fm API. If unset, the now-playing API returns a graceful “not playing” / DB fallback without calling Last.fm. | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL. Paired with `SUPABASE_SERVICE_ROLE_KEY` inside `/api/spotify/now-playing` for the optional listening history. | +| `SUPABASE_SERVICE_ROLE_KEY` | **Server-only.** Used by `/api/spotify/now-playing` for DB reads/writes against `listening_history` / `listening_stats` — keep off the client bundle. | +| `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify app + user refresh token (scopes: `user-read-currently-playing`, `user-read-recently-played`). If unset, the route falls back to memory + DB for “last played.” | | `GITHUB_TOKEN` | Fine-grained or classic PAT for GitHub API (contributions + stars + **pinned repos** on the home page). If missing, contribution/stars features may error or return empty data; pinned projects fall back to a **static list** in [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts). | | `GITHUB_LOGIN` | Optional. GitHub username for **pinned repositories** and related API calls (defaults to `kaiiiichen` if unset). Set when forking so the home page shows your pins. | @@ -264,7 +270,7 @@ vercel env pull .env.vercel.check That path is gitignored — do not commit it. -### CI +### CI builds without live Supabase CI does not need any real Supabase keys to build — every Supabase client in this repo is constructed lazily inside a function body, so `next build` succeeds without `NEXT_PUBLIC_SUPABASE_*` set. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml). @@ -274,21 +280,19 @@ CI does not need any real Supabase keys to build — every Supabase client in th | Service | Use in this repo | | --- | --- | -| **Last.fm** | Recent / now-playing track | -| **Apple iTunes Search API** | Album art fallback | +| **Spotify Web API** | Current + recently played track | | **GitHub GraphQL** | Contribution calendar | | **GitHub REST** | Repo stars, commit search | -| **Open-Meteo** | Weather (no API key) | -| **Supabase** | Optional `listening_history` / `listening_stats` writes (service role) for the now-playing route | -| **Substack RSS** | Home page “latest posts” (`app/lib/substack.ts`) | -| **lib.berkeley.edu** | Library hours HTML (scraped server-side; not an official API) | +| **Open-Meteo** | Weather (no API key); Berkeley lat/long in [`app/api/weather/route.ts`](app/api/weather/route.ts) | +| **Supabase** | Optional `listening_history` / `listening_stats` writes (service role) for `/api/spotify/now-playing` | +| **Substack RSS** | Home page “latest posts” ([`app/lib/substack.ts`](app/lib/substack.ts)) | --- ## Local development - **Node 20**, **npm install** then **`npm run dev`**. -- **Supabase:** only `/api/lastfm/now-playing` uses Supabase (service role) for the optional listening history. The site builds and serves every page without any Supabase env set; the route just falls back to live Last.fm + in-memory cache when the DB is unreachable. +- **Supabase:** only `/api/spotify/now-playing` uses Supabase (service role) for the optional listening history. The site builds without Supabase env; when it is unset, the route skips DB reads/writes and still uses Spotify if `SPOTIFY_*` is configured. ### Common issues @@ -302,7 +306,7 @@ CI does not need any real Supabase keys to build — every Supabase client in th ## Testing -Unit tests use **Vitest** and live next to helpers under `lib/*.test.ts` (RSS parsing, weather mapping, Last.fm helpers). +Unit tests use **Vitest** and live under `lib/*.test.ts`: [`lib/substack-rss.test.ts`](lib/substack-rss.test.ts), [`lib/weather-open-meteo.test.ts`](lib/weather-open-meteo.test.ts), [`lib/spotify-now-playing-helpers.test.ts`](lib/spotify-now-playing-helpers.test.ts). ```bash npm run test @@ -358,7 +362,7 @@ to non-merge commits via `git interpret-trailers` (idempotent). Automation that ## Deployment 1. Connect the GitHub repository to **Vercel**. -2. Set environment variables in the Vercel project (production + preview as needed), especially `GITHUB_TOKEN`, `LASTFM_API_KEY`, and the Supabase keys if you want listening history persistence. +2. Set environment variables in the Vercel project (production + preview as needed), especially `GITHUB_TOKEN`, Spotify keys, and the Supabase variables if you want listening history persistence. 3. Pushes to `main` typically deploy production; preview deployments use PR branches. Manual CLI (after `vercel link`): @@ -392,10 +396,10 @@ Replace at minimum: | Area | Where to look | | --- | --- | -| Copy, links, projects list | `app/page.tsx`, `app/projects/page.tsx`, `app/about/page.tsx` | -| Last.fm username | `app/api/lastfm/now-playing/route.ts` | +| Copy, links, projects list, social URLs | `app/page.tsx`, `app/projects/page.tsx`, `app/about/page.tsx` | +| Spotify OAuth app + refresh token | Spotify Developer Dashboard; env `SPOTIFY_*` consumed in `lib/spotify-access-token.ts` | | GitHub login / repos / pins | `app/api/github/contributions/route.ts`, `app/components/project-stars.tsx`, [`app/lib/github-pinned.ts`](app/lib/github-pinned.ts), env `GITHUB_LOGIN` | -| Supabase tables | `app/api/lastfm/now-playing/route.ts`, Supabase dashboard (`listening_history`, `listening_stats`) | +| Supabase tables | `lib/listening-supabase.ts`, `app/api/spotify/now-playing/route.ts`, Supabase dashboard (`listening_history`, `listening_stats`) | | Substack feeds | `app/lib/substack.ts` | | Weather location | `app/api/weather/route.ts`, weather UI components | | Theme / fonts | `app/layout.tsx`, `app/globals.css`, `app/components/theme-provider.tsx` | diff --git a/SECURITY.md b/SECURITY.md index 3f5f4bf..9303f3a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,7 @@ Instead: ## Secrets hygiene (for contributors) - **Never commit** `.env.local`, API tokens, `SENTRY_AUTH_TOKEN`, Supabase **service role** keys, or GitHub personal access tokens. -- If a secret was ever committed — even briefly — **rotate it** in the provider (Supabase, GitHub, Last.fm, Vercel, Sentry) and **purge** from git history if the repo was public. +- If a secret was ever committed — even briefly — **rotate it** in the provider (Supabase, GitHub, Spotify, Vercel, Sentry) and **purge** from git history if the repo was public. - Files such as `.env.vercel.check` from `vercel env pull` are **gitignored**; treat them like secrets on disk. See also [README.md — Environment variables](README.md#environment-variables) and [CONTRIBUTING.md](CONTRIBUTING.md). @@ -61,7 +61,7 @@ Automated **Dependabot** PRs and the [auto-merge workflow](.github/workflows/aut Reports may be **declined** or redirected when they concern: -- Third-party services’ policies (Last.fm, GitHub, Vercel, Supabase product bugs) — use their official channels. +- Third-party services’ policies (Spotify, GitHub, Vercel, Supabase product bugs) — use their official channels. - **Social engineering** or account takeover of maintainer accounts outside this codebase. - **Theoretical** issues without a plausible attack path against deployed configuration. diff --git a/app/about/page.tsx b/app/about/page.tsx index 780ab0c..ae173ba 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -17,7 +17,7 @@ export default function About() { {/* Bio */}

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() {

{term}

{code}

-

{name}

+

{name}

); }); diff --git a/app/api/lastfm/now-playing/route.ts b/app/api/lastfm/now-playing/route.ts deleted file mode 100644 index 6bb9259..0000000 --- a/app/api/lastfm/now-playing/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { NextResponse } from "next/server"; -import { createClient } from "@supabase/supabase-js"; -import type { NowPlayingResult } from "@/lib/now-playing"; -import { - isLastFmTrackPlaying, - pickAlbumArtFromLastFmImages, - type LastFmImageEntry, -} from "@/lib/lastfm-now-playing-helpers"; - -const LASTFM_API_URL = "https://ws.audioscrobbler.com/2.0/"; - -function getServiceSupabase() { - return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); -} - -async function getLastPlayedFromDb() { - try { - const db = getServiceSupabase(); - 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; - } -} - -// In-memory fallback — survives across requests within the same server instance -let lastKnownTrack: { title: string; artist: string; albumArt: string; songUrl: string } | null = null; - -export async function GET() { - const apiKey = process.env.LASTFM_API_KEY; - - const notPlayingResponse = async (): Promise => ({ - isPlaying: false, - recentTrack: lastKnownTrack ?? (await getLastPlayedFromDb()), - }); - - if (!apiKey) { - return NextResponse.json(await notPlayingResponse(), { - headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, - }); - } - - let res: Response; - try { - res = await fetch( - `${LASTFM_API_URL}?method=user.getrecenttracks&user=kaiiiichen&api_key=${apiKey}&format=json&limit=1`, - { cache: "no-store" } - ); - } catch { - return NextResponse.json(await notPlayingResponse(), { - headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, - }); - } - - if (!res.ok) { - return NextResponse.json(await notPlayingResponse(), { - headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, - }); - } - - const json = await res.json(); - const tracks = json?.recenttracks?.track; - if (!tracks || tracks.length === 0) { - return NextResponse.json(await notPlayingResponse(), { - headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, - }); - } - - const track = tracks[0]; - - const isPlaying: boolean = isLastFmTrackPlaying(track, new Date()); - - const title: string = track.name ?? ""; - const artist: string = track.artist?.["#text"] ?? track.artist ?? ""; - const album: string = track.album?.["#text"] ?? ""; - const images: LastFmImageEntry[] = track.image || []; - let albumArt: string = pickAlbumArtFromLastFmImages(images); - - // Fallback: fetch album art from iTunes Search API - if (!albumArt) { - try { - const itunesRes = await fetch( - `https://itunes.apple.com/search?term=${encodeURIComponent(`${artist} ${title}`)}&media=music&limit=1`, - { cache: "no-store" } - ); - if (itunesRes.ok) { - const itunesData = await itunesRes.json(); - const art100: string = itunesData?.results?.[0]?.artworkUrl100 ?? ""; - if (art100) albumArt = art100.replace("100x100", "600x600"); - } - } catch { - // Non-critical - } - } - - const songUrl = `https://music.apple.com/search?term=${encodeURIComponent(`${title} ${artist}`)}`; - const playedAt = track.date?.uts ? parseInt(track.date.uts, 10) * 1000 : undefined; - - lastKnownTrack = { title, artist, albumArt, songUrl }; - - if (!isPlaying) { - return NextResponse.json( - { isPlaying: false, recentTrack: { title, artist, albumArt, songUrl, playedAt } } satisfies NowPlayingResult, - { headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" } } - ); - } - - // Fire-and-forget Supabase write - const trackId: string = track.mbid || `${artist}::${title}`; - const albumId: string = track.album?.mbid || `album::${artist}::${album}`; - - void (async () => { - try { - const db = getServiceSupabase(); - await db.from("listening_history").insert({ - track_id: trackId, - track_name: title, - artist_name: artist, - album_id: albumId, - album_name: album, - album_art: albumArt, - song_url: songUrl, - }); - const { data: existing } = await db - .from("listening_stats") - .select("play_count") - .eq("track_id", trackId) - .single(); - await db.from("listening_stats").upsert( - { - track_id: trackId, - track_name: title, - artist_name: artist, - album_id: albumId, - album_name: album, - album_art: albumArt, - song_url: songUrl, - play_count: (existing?.play_count ?? 0) + 1, - last_played_at: new Date().toISOString(), - }, - { onConflict: "track_id" } - ); - } catch { - // Non-critical - } - })(); - - return NextResponse.json( - { - isPlaying: true, - title, - artist, - albumArt, - songUrl, - progress_ms: 0, - duration_ms: 0, - } satisfies NowPlayingResult, - { headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" } } - ); -} diff --git a/app/api/spotify/now-playing/route.ts b/app/api/spotify/now-playing/route.ts new file mode 100644 index 0000000..c3eceae --- /dev/null +++ b/app/api/spotify/now-playing/route.ts @@ -0,0 +1,314 @@ +import { NextResponse } from "next/server"; +import type { NowPlayingResult } from "@/lib/now-playing"; +import { + createListeningSupabase, + getLastPlayedListeningRow, + upsertListeningStatsMerged, +} from "@/lib/listening-supabase"; +import { + getSpotifyAccessToken, + invalidateSpotifyAccessTokenCache, +} from "@/lib/spotify-access-token"; +import { + formatSpotifyArtistNames, + pickAlbumArtFromSpotifyImages, +} from "@/lib/spotify-now-playing-helpers"; + +const CURRENTLY_PLAYING_URL = "https://api.spotify.com/v1/me/player/currently-playing"; +const RECENTLY_PLAYED_URL = "https://api.spotify.com/v1/me/player/recently-played?limit=1"; + +type SpotifyTrack = { + id: string; + name?: string; + artists?: { name?: string }[]; + album?: { + id?: string; + name?: string; + images?: { url: string; width?: number | null; height?: number | null }[]; + }; + duration_ms?: number; + external_urls?: { spotify?: string }; +}; + +type RecentlyPlayedPayload = { + items?: { track: SpotifyTrack; played_at?: string }[]; +}; + +type SpotifyAudioFeatures = { + tempo?: number; + energy?: number; + danceability?: number; + loudness?: number; +}; + +type TrackDynamics = { + bpm?: number; + intensity?: number; +}; + +const cacheControl = { + "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5", +} as const; + +let lastKnownTrack: { title: string; artist: string; albumArt: string; songUrl: string } | null = + null; +const dynamicsCache = new Map(); +let lastDynamicsTrackId: string | null = null; +let lastDynamics: TrackDynamics | undefined; + +function mapTrack(track: SpotifyTrack): { + trackDbId: string; + title: string; + artist: string; + albumArt: string; + songUrl: string; + albumName: string; + albumDbId: string; + duration_ms: number; +} | null { + if (!track.id) return null; + const title = track.name ?? ""; + const artist = formatSpotifyArtistNames(track.artists); + const albumArt = pickAlbumArtFromSpotifyImages(track.album?.images); + const songUrl = + track.external_urls?.spotify ?? + `https://open.spotify.com/track/${encodeURIComponent(track.id)}`; + const albumName = track.album?.name ?? ""; + const albumDbId = track.album?.id + ? `spotify-album:${track.album.id}` + : `album::${artist}::${albumName}`; + return { + trackDbId: `spotify:${track.id}`, + title, + artist, + albumArt, + songUrl, + albumName, + albumDbId, + duration_ms: typeof track.duration_ms === "number" ? track.duration_ms : 0, + }; +} + +function schedulePersistPlayingTrack(mapped: NonNullable>) { + void (async () => { + try { + const dbPersist = createListeningSupabase(); + if (!dbPersist) return; + + await dbPersist.from("listening_history").insert({ + track_id: mapped.trackDbId, + track_name: mapped.title, + artist_name: mapped.artist, + album_id: mapped.albumDbId, + album_name: mapped.albumName, + album_art: mapped.albumArt, + song_url: mapped.songUrl, + }); + + await upsertListeningStatsMerged(dbPersist, { + track_id: mapped.trackDbId, + track_name: mapped.title, + artist_name: mapped.artist, + album_id: mapped.albumDbId, + album_name: mapped.albumName, + album_art: mapped.albumArt, + song_url: mapped.songUrl, + }); + } catch { + // Non-critical + } + })(); +} + +async function fetchSpotify(accessToken: string, url: string) { + return fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + cache: "no-store", + }); +} + +function toUnit(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return 0; + if (max <= min) return 0; + return Math.max(0, Math.min(1, (value - min) / (max - min))); +} + +function deriveIntensity(features: SpotifyAudioFeatures): number | undefined { + if ( + typeof features.energy !== "number" && + typeof features.danceability !== "number" && + typeof features.loudness !== "number" + ) { + return undefined; + } + const energy = typeof features.energy === "number" ? features.energy : 0.5; + const danceability = typeof features.danceability === "number" ? features.danceability : 0.5; + // Loudness is usually in [-60, 0], where closer to 0 feels stronger. + const loudnessNormalized = + typeof features.loudness === "number" ? toUnit(features.loudness, -30, -4) : 0.5; + const intensity = energy * 0.55 + loudnessNormalized * 0.3 + danceability * 0.15; + return Math.max(0, Math.min(1, intensity)); +} + +async function getTrackDynamics(accessToken: string, trackId: string): Promise { + const cached = dynamicsCache.get(trackId); + if (cached) { + return cached; + } + + try { + const res = await fetchSpotify(accessToken, `https://api.spotify.com/v1/audio-features/${trackId}`); + if (!res.ok) return {}; + const json = (await res.json()) as SpotifyAudioFeatures; + const tempo = typeof json.tempo === "number" ? json.tempo : undefined; + const dynamics: TrackDynamics = { + bpm: tempo && Number.isFinite(tempo) ? tempo : undefined, + intensity: deriveIntensity(json), + }; + dynamicsCache.set(trackId, dynamics); + return dynamics; + } catch { + return {}; + } +} + +export async function GET() { + const db = createListeningSupabase(); + + const notPlayingResponse = async (): Promise => ({ + isPlaying: false, + recentTrack: lastKnownTrack ?? (await getLastPlayedListeningRow(db)), + }); + + let accessToken = await getSpotifyAccessToken(); + + if (!accessToken) { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } + + let currentRes = await fetchSpotify(accessToken, CURRENTLY_PLAYING_URL); + + if (currentRes.status === 401) { + invalidateSpotifyAccessTokenCache(); + accessToken = await getSpotifyAccessToken(); + if (!accessToken) { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } + currentRes = await fetchSpotify(accessToken, CURRENTLY_PLAYING_URL); + } + + if (currentRes.status === 200) { + try { + const body: { is_playing?: boolean; progress_ms?: number; item?: SpotifyTrack | null } = + await currentRes.json(); + const item = body.item; + if (item?.id) { + const mapped = mapTrack(item); + if (mapped) { + lastKnownTrack = { + title: mapped.title, + artist: mapped.artist, + albumArt: mapped.albumArt, + songUrl: mapped.songUrl, + }; + + const playing = Boolean(body.is_playing); + const progress_ms = + typeof body.progress_ms === "number" ? body.progress_ms : 0; + + if (!playing) { + return NextResponse.json( + { + isPlaying: false, + recentTrack: { + title: mapped.title, + artist: mapped.artist, + albumArt: mapped.albumArt, + songUrl: mapped.songUrl, + }, + } satisfies NowPlayingResult, + { headers: cacheControl } + ); + } + + schedulePersistPlayingTrack(mapped); + + let dynamics: TrackDynamics | undefined; + if (lastDynamicsTrackId === item.id && lastDynamics) { + dynamics = lastDynamics; + } else { + dynamics = await getTrackDynamics(accessToken, item.id); + lastDynamicsTrackId = item.id; + lastDynamics = dynamics; + } + + return NextResponse.json( + { + isPlaying: true, + title: mapped.title, + artist: mapped.artist, + albumArt: mapped.albumArt, + songUrl: mapped.songUrl, + progress_ms, + duration_ms: mapped.duration_ms, + bpm: dynamics?.bpm, + intensity: dynamics?.intensity, + } satisfies NowPlayingResult, + { headers: cacheControl } + ); + } + } + } catch { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } + } + + let recentRes = await fetchSpotify(accessToken, RECENTLY_PLAYED_URL); + + if (recentRes.status === 401) { + invalidateSpotifyAccessTokenCache(); + const t2 = await getSpotifyAccessToken(); + if (t2) { + recentRes = await fetchSpotify(t2, RECENTLY_PLAYED_URL); + } + } + + if (!recentRes.ok) { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } + + try { + const payload = (await recentRes.json()) as RecentlyPlayedPayload; + const raw = payload.items?.[0]; + const mapped = raw?.track ? mapTrack(raw.track) : null; + if (!mapped) { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } + + lastKnownTrack = { + title: mapped.title, + artist: mapped.artist, + albumArt: mapped.albumArt, + songUrl: mapped.songUrl, + }; + + const playedAt = + typeof raw?.played_at === "string" ? Date.parse(raw.played_at) : undefined; + + return NextResponse.json( + { + isPlaying: false, + recentTrack: { + title: mapped.title, + artist: mapped.artist, + albumArt: mapped.albumArt, + songUrl: mapped.songUrl, + playedAt: Number.isFinite(playedAt) ? playedAt : undefined, + }, + } satisfies NowPlayingResult, + { headers: cacheControl } + ); + } catch { + return NextResponse.json(await notPlayingResponse(), { headers: cacheControl }); + } +} diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts index ad33a0d..72ffe94 100644 --- a/app/api/weather/route.ts +++ b/app/api/weather/route.ts @@ -8,7 +8,7 @@ export async function GET() { const url = "https://api.open-meteo.com/v1/forecast" + "?latitude=37.8715&longitude=-122.2730" + - "¤t=temperature_2m,weathercode" + + "¤t=temperature_2m,weathercode,apparent_temperature,relative_humidity_2m" + "&hourly=precipitation_probability" + "&temperature_unit=celsius" + "&timezone=America%2FLos_Angeles" + diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx index e5ddbdd..1d4c5c2 100644 --- a/app/components/listening-card.tsx +++ b/app/components/listening-card.tsx @@ -1,68 +1,170 @@ "use client"; import { useNowPlaying } from "@/app/hooks/use-now-playing"; +import type { CSSProperties } from "react"; -export default function ListeningCard() { - const { displayItem, dotPlaying } = useNowPlaying(); +function getAnimationDurations(bpm?: number) { + if (!bpm || !Number.isFinite(bpm)) { + return { eqMs: 820, pulseMs: 2200 }; + } + const normalized = Math.max(72, Math.min(180, bpm)); + const beatMs = 60000 / normalized; + return { + eqMs: Math.round(beatMs * 1.6), + pulseMs: Math.round(beatMs * 4), + }; +} - return ( -
- {displayItem ? ( - - {displayItem.albumArt ? ( - // eslint-disable-next-line @next/next/no-img-element - {displayItem.title} - ) : ( -
- )} -
-

- {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 + }; +} -
- +/** Quiet empty state — static etched rings */ +function ListeningEmptyVisual() { + return ( +
+
+ + + + + +
+
- {dotPlaying ? "now playing" : "last played"} + No signal + + + Nothing on the speakers yet—Spotify picks this up automatically when you listen.
); } + +export default function ListeningCard() { + const { data, displayItem, dotPlaying, slideClass } = useNowPlaying(); + const hasTrack = Boolean(displayItem); + const live = hasTrack && dotPlaying; + const bpm = data?.isPlaying ? data.bpm : undefined; + const intensity = data?.isPlaying ? data.intensity : undefined; + const { pulseMs } = getAnimationDurations(bpm); + const tuning = getWaveTuning(intensity); + + const stateText = live ? "now playing" : hasTrack ? "last played" : "idle"; + + return ( +
+ + ); +} diff --git a/app/components/weather-card.tsx b/app/components/weather-card.tsx index b1c4f12..b9b0ef2 100644 --- a/app/components/weather-card.tsx +++ b/app/components/weather-card.tsx @@ -5,6 +5,8 @@ import WeatherIllustration from "./WeatherIllustration"; type WeatherData = { temperature: number; + feelsLike: number; + humidity: number; weatherCode: number; emoji: string; condition: string; @@ -19,7 +21,7 @@ export default function WeatherCard() { fetch("/api/weather") .then((r) => r.json()) .then(setW) - .catch(() => {}); + .catch(() => { }); }, []); const displayTemp = w @@ -29,9 +31,9 @@ export default function WeatherCard() { : "—°"; return ( -
- {/* Location + time row */} -
+
+ {/* Row 1: Location + time */} +

· CA · US

-
- {/* Left: temperature + condition */} -
-
- + {/* Row 2: temperature + condition + illustration */} +
+ {/* Left: temperature + condition */} +
+
+ +
+ +
+

+ {w ? w.condition : "—"} +

+
-
-

- {w ? w.condition : "—"} -

+ {/* Right: weather illustration */} +
+
- {/* Right: weather illustration */} -
- + {/* Row 3: auxiliary weather line */} +
+ + {w ? `feels ${w.feelsLike}°C · humidity ${w.humidity}%` : "Feels — · Humidity —"} +
-
); } diff --git a/app/globals.css b/app/globals.css index 60e7e91..e3b63ec 100644 --- a/app/globals.css +++ b/app/globals.css @@ -14,6 +14,8 @@ --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 */ @@ -61,6 +63,7 @@ body { /* ── Magazine / Editorial card ──────────────────────────────── */ .mag-card { + font-family: var(--font-ui-en); background: #ffffff; border: 1px solid var(--color-border-secondary); border-bottom: 3px solid var(--color-border-primary); @@ -87,16 +90,15 @@ body { background: #2a221c; } -/* Magazine label — Nunito 400, uppercase + right-extending hairline */ +/* Magazine label — same UI font as nav (Nunito), title case + hairline */ .mag-label { display: flex; align-items: center; gap: 0.625rem; - font-family: 'Nunito', sans-serif; + font-family: var(--font-ui-en) !important; font-weight: 400; - font-size: 10px; + font-size: 14px; letter-spacing: 0.15em; - text-transform: uppercase; color: #a1a1aa; margin-bottom: 1rem; } @@ -129,6 +131,565 @@ body { .slide-exit { animation: slide-out-left 200ms ease forwards; } .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% { + box-shadow: 0 0 0 0 rgb(29 185 84 / 0.45); + } + 55% { + box-shadow: 0 0 0 10px rgb(29 185 84 / 0); + } +} +@keyframes listening-sheen-shift { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} +@keyframes listening-soft-breathe { + 0%, + 100% { + filter: saturate(1) brightness(1); + } + 50% { + filter: saturate(1.06) brightness(1.03); + } +} + +.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; +} + /* Non-home routes: gradient veil + content entrance */ .subpage-enter { position: relative; @@ -219,4 +780,42 @@ 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%; + } + .listening-art-live { + animation: none !important; + } } diff --git a/app/hooks/use-now-playing.ts b/app/hooks/use-now-playing.ts index b8e532c..553a928 100644 --- a/app/hooks/use-now-playing.ts +++ b/app/hooks/use-now-playing.ts @@ -36,7 +36,7 @@ export function useNowPlaying(): UseNowPlayingReturn { activeController = new AbortController(); try { - const res = await fetch(`/api/lastfm/now-playing?t=${Date.now()}`, { + const res = await fetch(`/api/spotify/now-playing?t=${Date.now()}`, { cache: "no-store", signal: activeController.signal, }); diff --git a/app/layout.tsx b/app/layout.tsx index 6c82cf5..3dee55d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,9 +4,7 @@ import { GeistMono } from "geist/font/mono"; import "@fontsource/nunito/300.css"; import "@fontsource/nunito/400.css"; import "@fontsource/nunito/600.css"; -import "@fontsource/bitter/400.css"; -import "@fontsource/bitter/400-italic.css"; -import "@fontsource/bitter/600.css"; +import "@fontsource/nunito/600-italic.css"; import "@fontsource/jetbrains-mono/400.css"; import "@fontsource/jetbrains-mono/500.css"; import "./globals.css"; diff --git a/app/page.tsx b/app/page.tsx index e35a06e..f79c218 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -30,7 +30,7 @@ export default async function Home() { @@ -38,25 +38,25 @@ export default async function Home() { - + - + + + + + +
@@ -93,27 +104,27 @@ export default async function Home() { className="no-underline hover:opacity-70 transition-opacity duration-150" >🔸 -
-

Visiting UC Berkeley 2026

-

Maths at SUSTech · Fields Medal Honors Program

+
+

Visiting @ UC Berkeley 2026

+

Maths @ SUSTech ’27 · Fields Medal Honors Program

{/* Introductions */} -
-

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.

{/* Personality / side */} -
-

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.

@@ -146,7 +157,7 @@ export default async function Home() {
{projects.length === 0 ? (

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 ? (

{desc} @@ -203,7 +214,7 @@ export default async function Home() {

Blog
{substackPosts.length === 0 ? ( -

+

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() { Projects

Things I've built, maintained, or archived with love. @@ -29,7 +29,7 @@ export default async function Projects() {

{projects.length === 0 ? (

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:`, summing `play_count`. + */ +export async function upsertListeningStatsMerged( + db: SupabaseClient, + incoming: ListeningStatsUpsertPayload +): Promise { + const { data: matchingRows } = await db + .from("listening_stats") + .select("track_id, play_count") + .eq("track_name", incoming.track_name) + .eq("artist_name", incoming.artist_name); + + const rows = matchingRows ?? []; + let mergedFromSpotifyRow = 0; + let mergedFromOthers = 0; + + for (const row of rows) { + if (row.track_id === incoming.track_id) { + mergedFromSpotifyRow = row.play_count ?? 0; + } else { + mergedFromOthers += row.play_count ?? 0; + } + } + + const newTotal = mergedFromSpotifyRow + mergedFromOthers + 1; + + for (const row of rows) { + if (row.track_id !== incoming.track_id) { + await db.from("listening_stats").delete().eq("track_id", row.track_id); + } + } + + await db.from("listening_stats").upsert( + { + ...incoming, + play_count: newTotal, + last_played_at: new Date().toISOString(), + }, + { onConflict: "track_id" } + ); +} diff --git a/lib/now-playing.ts b/lib/now-playing.ts index 17deacb..b543d6f 100644 --- a/lib/now-playing.ts +++ b/lib/now-playing.ts @@ -16,4 +16,6 @@ export type NowPlayingResult = songUrl: string; progress_ms: number; duration_ms: number; + bpm?: number; + intensity?: number; }; diff --git a/lib/spotify-access-token.ts b/lib/spotify-access-token.ts new file mode 100644 index 0000000..5d8be9f --- /dev/null +++ b/lib/spotify-access-token.ts @@ -0,0 +1,61 @@ +const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; + +let tokenCache: { accessToken: string; expiresAtMs: number } | null = null; + +function clearCache() { + tokenCache = null; +} + +/** Server-only user access token via refresh-token grant (Spotify Accounts service). */ +export async function getSpotifyAccessToken(): Promise { + const clientId = process.env.SPOTIFY_CLIENT_ID; + const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; + const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN; + if (!clientId || !clientSecret || !refreshToken) { + return null; + } + + const now = Date.now(); + if (tokenCache && tokenCache.expiresAtMs > now + 30_000) { + return tokenCache.accessToken; + } + + let res: Response; + try { + res = await fetch(SPOTIFY_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + client_secret: clientSecret, + }), + cache: "no-store", + }); + } catch { + clearCache(); + return null; + } + + if (!res.ok) { + clearCache(); + return null; + } + + const json: { access_token?: string; expires_in?: number } = await res.json(); + const accessToken = json.access_token; + if (!accessToken) { + clearCache(); + return null; + } + + const expiresIn = typeof json.expires_in === "number" ? json.expires_in : 3600; + tokenCache = { accessToken, expiresAtMs: now + expiresIn * 1000 }; + return accessToken; +} + +/** Call after Spotify returns 401 so the next request refreshes proactively. */ +export function invalidateSpotifyAccessTokenCache() { + clearCache(); +} diff --git a/lib/spotify-now-playing-helpers.test.ts b/lib/spotify-now-playing-helpers.test.ts new file mode 100644 index 0000000..8e3de70 --- /dev/null +++ b/lib/spotify-now-playing-helpers.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + formatSpotifyArtistNames, + pickAlbumArtFromSpotifyImages, +} from "@/lib/spotify-now-playing-helpers"; + +describe("spotify now-playing helpers", () => { + it("picks widest album image by declared width", () => { + expect( + pickAlbumArtFromSpotifyImages([ + { url: "https://tiny", width: 64 }, + { url: "https://big", width: 640 }, + { url: "https://med", width: 300 }, + ]) + ).toBe("https://big"); + }); + + it("joins artists with commas", () => { + expect(formatSpotifyArtistNames([{ name: "A" }, { name: "B" }])).toBe("A, B"); + }); +}); diff --git a/lib/spotify-now-playing-helpers.ts b/lib/spotify-now-playing-helpers.ts new file mode 100644 index 0000000..7c5e366 --- /dev/null +++ b/lib/spotify-now-playing-helpers.ts @@ -0,0 +1,25 @@ +export type SpotifyImage = { url: string; height?: number | null; width?: number | null }; + +export type SpotifyArtist = { name?: string }; + +/** Album art URL: prefer widest image Spotify returns for the album. */ +export function pickAlbumArtFromSpotifyImages(images: SpotifyImage[] | undefined): string { + if (!images?.length) return ""; + let best = images[0]!; + let bestW = best.width ?? 0; + for (const img of images) { + const w = img.width ?? 0; + if (w > bestW) { + best = img; + bestW = w; + } + } + return best?.url ?? ""; +} + +export function formatSpotifyArtistNames(artists: SpotifyArtist[] | undefined): string { + return (artists ?? []) + .map((a) => a.name ?? "") + .filter(Boolean) + .join(", "); +} diff --git a/lib/weather-open-meteo.test.ts b/lib/weather-open-meteo.test.ts index 835627a..f21879d 100644 --- a/lib/weather-open-meteo.test.ts +++ b/lib/weather-open-meteo.test.ts @@ -5,7 +5,12 @@ describe("parseOpenMeteoForecast", () => { it("maps current fields, WMO lookup, and rain chance from hourly window", () => { const now = new Date("2026-03-28T14:30:00.000Z"); const data: OpenMeteoForecastPayload = { - current: { temperature_2m: 18.4, weathercode: 0 }, + current: { + temperature_2m: 18.4, + weathercode: 0, + apparent_temperature: 16.8, + relative_humidity_2m: 82.3, + }, hourly: { time: [ "2026-03-28T13:00", @@ -18,6 +23,8 @@ describe("parseOpenMeteoForecast", () => { }; const out = parseOpenMeteoForecast(data, now); expect(out.temperature).toBe(18); + expect(out.feelsLike).toBe(17); + expect(out.humidity).toBe(82); expect(out.weatherCode).toBe(0); expect(out.emoji).toBe("☀️"); expect(out.condition).toBe("Clear"); diff --git a/lib/weather-open-meteo.ts b/lib/weather-open-meteo.ts index 2f8a049..ea9924c 100644 --- a/lib/weather-open-meteo.ts +++ b/lib/weather-open-meteo.ts @@ -23,12 +23,19 @@ const WMO: Record = { }; export type OpenMeteoForecastPayload = { - current: { temperature_2m: number; weathercode: number }; + current: { + temperature_2m: number; + weathercode: number; + apparent_temperature: number; + relative_humidity_2m: number; + }; hourly: { time: string[]; precipitation_probability: (number | null)[] }; }; export type ParsedOpenMeteoForecast = { temperature: number; + feelsLike: number; + humidity: number; weatherCode: number; emoji: string; condition: string; @@ -41,6 +48,8 @@ export function parseOpenMeteoForecast( now: Date = new Date() ): ParsedOpenMeteoForecast { const temperature = Math.round(data.current.temperature_2m); + const feelsLike = Math.round(data.current.apparent_temperature); + const humidity = Math.round(data.current.relative_humidity_2m); const weatherCode = data.current.weathercode; const { emoji, condition } = WMO[weatherCode] ?? { emoji: "🌡️", condition: "Unknown" }; @@ -51,5 +60,5 @@ export function parseOpenMeteoForecast( const nextProbs = idx >= 0 ? probs.slice(idx, idx + 3) : probs.slice(0, 3); const rainChance = Math.max(...nextProbs.map((p) => p ?? 0)); - return { temperature, weatherCode, emoji, condition, rainChance }; + return { temperature, feelsLike, humidity, weatherCode, emoji, condition, rainChance }; } diff --git a/next.config.ts b/next.config.ts index 7a546fe..f0f2f1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -16,7 +16,6 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "i.scdn.co" }, - { protocol: "https", hostname: "lastfm.freetls.fastly.net" }, { protocol: "https", hostname: "*.mzstatic.com" }, ], }, diff --git a/package-lock.json b/package-lock.json index ee6bba5..ee2da50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@fontsource/bitter": "^5.2.10", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/nunito": "^5.2.7", "@mdx-js/loader": "^3.1.1", @@ -558,15 +557,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@fontsource/bitter": { - "version": "5.2.10", - "resolved": "https://registry.npmjs.org/@fontsource/bitter/-/bitter-5.2.10.tgz", - "integrity": "sha512-oqQgWAp+lgoGX7iFbVSbTkKyz0k8EK8+bLdUKDTqGTjW0HqrCue70v4jwbZvyNV/3DZol/b/L/WpdXfuQXKBUg==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, "node_modules/@fontsource/jetbrains-mono": { "version": "5.2.8", "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", diff --git a/package.json b/package.json index 1d0e82c..c53bdcd 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "postinstall": "git config core.hooksPath .githooks 2>/dev/null || true" }, "dependencies": { - "@fontsource/bitter": "^5.2.10", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/nunito": "^5.2.7", "@mdx-js/loader": "^3.1.1", From b2d42f32a98bf793129fcff00215f0c7faeb7bed Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Thu, 7 May 2026 22:41:53 -0700 Subject: [PATCH 2/3] style: tighten weather card line spacing Reduce weather card vertical rhythm so the location block reads denser without changing typography scale. Co-authored-by: Cursor Co-authored-by: Claude --- app/components/weather-card.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/components/weather-card.tsx b/app/components/weather-card.tsx index b9b0ef2..4140372 100644 --- a/app/components/weather-card.tsx +++ b/app/components/weather-card.tsx @@ -31,12 +31,12 @@ export default function WeatherCard() { : "—°"; return ( -

+
{/* Row 1: Location + time */}

Berkeley · CA · US

@@ -83,7 +83,7 @@ export default function WeatherCard() {
{w ? `feels ${w.feelsLike}°C · humidity ${w.humidity}%` : "Feels — · Humidity —"} From fb5a29a79e69fcc43a718c5f13dfffdbde8965c2 Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Thu, 7 May 2026 22:45:35 -0700 Subject: [PATCH 3/3] fix: escape apostrophe in homepage copy Resolve react/no-unescaped-entities failure in CI by escaping the bachelor's apostrophe in app/page.tsx. Co-authored-by: Cursor Co-authored-by: Claude --- app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index f79c218..601afb3 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -122,7 +122,7 @@ export default async function Home() { {/* 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 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.