From 6b384d70d802ff2afd0d7239efed2bf12bd4e27b Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Sat, 4 Jul 2026 20:22:51 -0700 Subject: [PATCH 1/2] feat: add /playlists page for curated public Spotify playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spotify's Feb-2026 API changes blocked listing playlists automatically (/me/playlists and /users/{id}/playlists return 403 in Development Mode), so playlist IDs are curated by hand in app/lib/spotify-playlists.ts — array order is display order. Covers, descriptions and tracks are fetched live from GET /playlists/{id} and the new /items endpoint (revalidate 1h). Entry chip sits on the home Listening card next to last month's tops. Co-authored-by: Cursor Co-authored-by: Claude --- README.md | 11 ++- app/components/hover-link-hint.tsx | 1 + app/components/listening-card.tsx | 6 +- app/lib/spotify-playlists.ts | 90 ++++++++++++++++++++ app/playlists/opengraph-image.tsx | 8 ++ app/playlists/page.tsx | 129 +++++++++++++++++++++++++++++ app/sitemap.ts | 1 + lib/spotify-playlists.test.ts | 90 ++++++++++++++++++++ lib/spotify-playlists.ts | 99 ++++++++++++++++++++++ next.config.ts | 3 + 10 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 app/lib/spotify-playlists.ts create mode 100644 app/playlists/opengraph-image.tsx create mode 100644 app/playlists/page.tsx create mode 100644 lib/spotify-playlists.test.ts create mode 100644 lib/spotify-playlists.ts diff --git a/README.md b/README.md index 3ea1111..8fdc422 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,7 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende | `/` | First impression — identity, social links, Listening, Location, Projects | | `/about` | Deeper self — education, Focus, experience, volunteering | | `/projects` | Course Projects, Personal Projects, GitHub Activity | +| `/playlists` | Public Spotify playlists — covers, descriptions, tracks | | `/misc` | Watching, Remembrance, Things I Love | @@ -202,6 +203,7 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal project cards, GitHub heatmap │ │ └── oxford-dul-2025/ # Oxford summer coursework portfolio (landing + [slug]) │ ├── misc/ # Watching, Remembrance, Things I Love + OG +│ ├── playlists/ # Public Spotify playlists (covers, tracks) + OG │ ├── api/ │ │ ├── spotify/now-playing/ │ │ ├── spotify/last-month-top/ @@ -215,6 +217,7 @@ kaichen.dev/ │ ├── lib/ │ │ ├── oxford-dul-projects.ts # Oxford portfolio metadata + 6 projects │ │ ├── course-projects.ts # Course Projects entries (Oxford + Notion notes) +│ │ ├── spotify-playlists.ts # Curated playlist IDs + server fetcher │ │ ├── og.tsx │ │ └── github-pinned.ts │ ├── sitemap.ts @@ -243,6 +246,7 @@ kaichen.dev/ | `/projects` | **Course Projects** cards; **Personal Projects** grid; GitHub contribution calendar | | `/projects/oxford-dul-2025` | Oxford **Deep Unsupervised Learning** landing — overview, learning path, 6 project cards, academic record | | `/projects/oxford-dul-2025/{slug}` | Per-project detail — Problem, Dataset, Approach, Training, My Work, Evaluation, Results, Takeaway | +| `/playlists` | **Public Spotify playlists** — curated IDs in `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` (display order = array order); covers, descriptions and tracks fetched live (`revalidate = 3600`). Entry chip on the home Listening card | | `/misc` | **Watching** (dated news links), **Remembrance**, **Things I Love** (nested category inset cards) | @@ -290,7 +294,7 @@ vercel env pull .env.vercel.check # gitignored — do not commit | Service | Use | | ------------------- | ------------------------------------------------------ | -| **Spotify Web API** | Now playing + recently played | +| **Spotify Web API** | Now playing + recently played + public playlists | | **GitHub GraphQL** | Contribution calendar + pinned repos (stars, archived) | | **Open-Meteo** | Berkeley weather (no key) | | **Supabase** | Optional listening history persistence | @@ -347,6 +351,7 @@ vercel --prod # after vercel link | Oxford portfolio | `[app/lib/oxford-dul-projects.ts](app/lib/oxford-dul-projects.ts)`, `app/projects/oxford-dul-2025/`, `app/components/oxford-dul/` | | Portfolio PNG assets | `public/portfolio/oxford-dul-2025/{slug}/` — see `[portfolio/oxford-dul-2025/README.md](portfolio/oxford-dul-2025/README.md)` | | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*` in `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | +| Spotify playlists | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` — `SPOTIFY_PLAYLIST_IDS` (order = display order) | | GitHub login / pins | `GITHUB_LOGIN`, `[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`, contributions route | | Supabase schema | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`, now-playing route | | Weather location | `[app/api/weather/route.ts](app/api/weather/route.ts)` | @@ -438,6 +443,7 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | `/` | 第一印象 —— 身份、社交链接、Listening、Location、Projects | | `/about` | 更深的自我 —— 教育、Focus、经历、志愿 | | `/projects` | Course Projects、Personal Projects、GitHub Activity | +| `/playlists` | 公开 Spotify 歌单——封面、简介、曲目 | | `/misc` | Watching、Remembrance、Things I Love | @@ -537,6 +543,7 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal 项目卡片、GitHub 热力图 │ │ └── oxford-dul-2025/ # 牛津暑校作品集(落地页 + [slug] 详情) │ ├── misc/ # Watching、Remembrance、Things I Love + OG +│ ├── playlists/ # 公开 Spotify 歌单(封面、曲目)+ OG │ ├── api/ │ │ ├── spotify/now-playing/ │ │ ├── spotify/last-month-top/ @@ -575,6 +582,7 @@ kaichen.dev/ | `/projects` | Course Projects 卡片;Personal Projects 网格;GitHub 贡献日历 | | `/projects/oxford-dul-2025` | 牛津 **Deep Unsupervised Learning** 落地页 | | `/projects/oxford-dul-2025/{slug}` | 单项目详情(Problem / Dataset / Training / Evaluation / Results 等) | +| `/playlists` | **公开 Spotify 歌单**——ID 列表在 `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`(数组顺序即展示顺序);封面、简介、曲目实时拉取(`revalidate = 3600`)。首页 Listening 卡片有入口 chip | | `/misc` | **Watching**、**Remembrance**、嵌套 **Things I Love** | @@ -681,6 +689,7 @@ vercel --prod # 需先 vercel link | 牛津作品集 | `[app/lib/oxford-dul-projects.ts](app/lib/oxford-dul-projects.ts)`、`app/projects/oxford-dul-2025/`、`app/components/oxford-dul/` | | 作品集 PNG 资源 | `public/portfolio/oxford-dul-2025/{slug}/` —— 见 `[portfolio/oxford-dul-2025/README.md](portfolio/oxford-dul-2025/README.md)` | | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*`,见 `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | +| Spotify 歌单 | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`——`SPOTIFY_PLAYLIST_IDS`(顺序即展示顺序) | | GitHub 用户 / 置顶 | `GITHUB_LOGIN`、`[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`、contributions 路由 | | Supabase 表结构 | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`、now-playing 路由 | | 天气坐标 | `[app/api/weather/route.ts](app/api/weather/route.ts)` | diff --git a/app/components/hover-link-hint.tsx b/app/components/hover-link-hint.tsx index 365df11..50ab91a 100644 --- a/app/components/hover-link-hint.tsx +++ b/app/components/hover-link-hint.tsx @@ -8,6 +8,7 @@ export function linkDestinationLabel(href: string): string { if (href.startsWith("/projects")) return "Projects"; if (href.startsWith("/about")) return "About"; if (href.startsWith("/misc")) return "Misc"; + if (href.startsWith("/playlists")) return "Playlists"; return "Site"; } diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx index 3847bbf..dd31078 100644 --- a/app/components/listening-card.tsx +++ b/app/components/listening-card.tsx @@ -4,6 +4,7 @@ import { useNowPlaying } from "@/app/hooks/use-now-playing"; import type { CSSProperties } from "react"; import ListeningLastMonthTop from "./listening-last-month-top"; import ListeningTrackRow from "./listening-track-row"; +import MagChip from "./mag-chip"; // Spotify's audio-features API was retired (403 for this app), so the live dot // uses fixed timings instead of per-track BPM/intensity. @@ -89,8 +90,11 @@ export default function ListeningCard() { > {stateText} -
+
+ + playlists +
diff --git a/app/lib/spotify-playlists.ts b/app/lib/spotify-playlists.ts new file mode 100644 index 0000000..4fc232b --- /dev/null +++ b/app/lib/spotify-playlists.ts @@ -0,0 +1,90 @@ +import { cache } from "react"; +import { getSpotifyAccessToken } from "@/lib/spotify-access-token"; +import { + mapSpotifyPlaylistMeta, + mapSpotifyPlaylistTracks, + type SpotifyPlaylist, + type SpotifyPlaylistItemsPayload, + type SpotifyPlaylistMetaPayload, +} from "@/lib/spotify-playlists"; + +/** + * Public playlists shown on /playlists — curated by hand, in display order. + * Spotify's Feb-2026 API changes blocked listing playlists automatically + * (`/me/playlists`, `/users/{id}/playlists` → 403 in Development Mode), so new + * playlists are added here. Covers, descriptions and tracks stay live. + */ +export const SPOTIFY_PLAYLIST_IDS: string[] = [ + "746sXyozXNMayFixOCxA7v", // Cider Cider + "44Z63BBu2uj55HJMJQxhEa", // California Sunshine + "4rzIwIqa44eT0FSA8gdCPd", // New York. New York! + "03Rdshz6jz93oIunaS07Np", // Shin Kyushu Yume + "3y7ByLsY6g8UaVaBqOCInL", // Great Britain Flash + "4CbQ6LAUr0ylc8xcgnrzP8", // Kanto Memory Forever + "7gXIWdOib64s0FoIsGVgYh", // Who am I? +]; + +const PLAYLISTS_REVALIDATE_SECONDS = 3600; +const ITEMS_PAGE_LIMIT = 100; + +const META_FIELDS = "id,name,description,images,external_urls"; +const ITEMS_FIELDS = + "total,next,items(item(type,id,name,artists(name),album(images),external_urls))"; + +async function fetchSpotifyJson(url: string, accessToken: string): Promise { + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + next: { revalidate: PLAYLISTS_REVALIDATE_SECONDS, tags: ["spotify-playlists"] }, + }); + if (!res.ok) return null; + return (await res.json()) as T; + } catch { + return null; + } +} + +async function fetchPlaylist( + id: string, + accessToken: string +): Promise { + const metaUrl = `https://api.spotify.com/v1/playlists/${id}?fields=${encodeURIComponent(META_FIELDS)}`; + const meta = await fetchSpotifyJson(metaUrl, accessToken); + if (!meta?.name) return null; + + const tracks: SpotifyPlaylist["tracks"] = []; + let total = 0; + let offset = 0; + + // The post-Feb-2026 `/items` endpoint (legacy `/tracks` now returns 403). + while (true) { + const itemsUrl = + `https://api.spotify.com/v1/playlists/${id}/items` + + `?limit=${ITEMS_PAGE_LIMIT}&offset=${offset}&fields=${encodeURIComponent(ITEMS_FIELDS)}`; + const page = await fetchSpotifyJson(itemsUrl, accessToken); + if (!page) break; + tracks.push(...mapSpotifyPlaylistTracks(page)); + total = page.total ?? tracks.length; + offset += ITEMS_PAGE_LIMIT; + if (!page.next || offset >= total) break; + } + + return { + ...mapSpotifyPlaylistMeta(meta, id), + trackCount: total || tracks.length, + tracks, + }; +} + +/** Curated public playlists with live metadata + tracks; [] when Spotify is unavailable. */ +export const getSpotifyPlaylists = cache( + async function getSpotifyPlaylists(): Promise { + const accessToken = await getSpotifyAccessToken(); + if (!accessToken) return []; + + const results = await Promise.all( + SPOTIFY_PLAYLIST_IDS.map((id) => fetchPlaylist(id, accessToken)) + ); + return results.filter((p): p is SpotifyPlaylist => p !== null); + } +); diff --git a/app/playlists/opengraph-image.tsx b/app/playlists/opengraph-image.tsx new file mode 100644 index 0000000..b6ab262 --- /dev/null +++ b/app/playlists/opengraph-image.tsx @@ -0,0 +1,8 @@ +import { OG_SIZE, OG_CONTENT_TYPE, makeOGImage } from "../lib/og"; + +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function OGImage() { + return makeOGImage("Playlists", "One per season, one per place"); +} diff --git a/app/playlists/page.tsx b/app/playlists/page.tsx new file mode 100644 index 0000000..fa99462 --- /dev/null +++ b/app/playlists/page.tsx @@ -0,0 +1,129 @@ +import type { Metadata } from "next"; +import Image from "next/image"; +import { HoverLinkArrow, HoverLinkDestinationHint } from "../components/hover-link-hint"; +import ListeningTrackRow from "../components/listening-track-row"; +import { getSpotifyPlaylists } from "../lib/spotify-playlists"; + +/** Covers, descriptions and tracks refresh from Spotify once an hour. */ +export const revalidate = 3600; +// The shared Spotify token helper uses a `no-store` fetch, which would +// otherwise opt this page into per-request dynamic rendering. +export const dynamic = "force-static"; + +export const metadata: Metadata = { + title: "Playlists — Kai T. Chen", + description: "Public Spotify playlists — one per season, one per place.", +}; + +export default async function Playlists() { + const playlists = await getSpotifyPlaylists(); + + return ( +
+ {/* Header */} +
+

+ Playlists +

+

+ Public playlists straight from my Spotify — mostly one per season, one per place. +

+
+ + {playlists.length === 0 ? ( +

+ Spotify isn't answering right now — check back in a bit. +

+ ) : ( + playlists.map((playlist, i) => ( +
+
+ {/* Playlist header — cover + name + description */} + + +
+ )) + )} +
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 3bb0e53..ecfe145 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "monthly" as const, priority: 0.7, })), + { url: `${BASE_URL}/playlists`, changeFrequency: "weekly", priority: 0.6 }, { url: `${BASE_URL}/misc`, changeFrequency: "monthly", priority: 0.5 }, ]; } diff --git a/lib/spotify-playlists.test.ts b/lib/spotify-playlists.test.ts new file mode 100644 index 0000000..3eaa927 --- /dev/null +++ b/lib/spotify-playlists.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + decodeSpotifyHtmlText, + mapSpotifyPlaylistMeta, + mapSpotifyPlaylistTracks, +} from "@/lib/spotify-playlists"; + +describe("decodeSpotifyHtmlText", () => { + it("decodes the entities Spotify escapes in descriptions", () => { + expect(decodeSpotifyHtmlText(""A & B" '26 <3")).toBe( + "\"A & B\" '26 <3" + ); + }); +}); + +describe("mapSpotifyPlaylistTracks", () => { + it("maps `/items` entries (track under `item`) to display tracks", () => { + expect( + mapSpotifyPlaylistTracks({ + items: [ + { + item: { + type: "track", + id: "abc", + name: "Song", + artists: [{ name: "A" }, { name: "B" }], + album: { images: [{ url: "https://img", width: 640 }] }, + external_urls: { spotify: "https://open.spotify.com/track/abc" }, + }, + }, + ], + }) + ).toEqual([ + { + title: "Song", + artist: "A, B", + albumArt: "https://img", + songUrl: "https://open.spotify.com/track/abc", + }, + ]); + }); + + it("skips episodes, local files, and empty entries", () => { + expect( + mapSpotifyPlaylistTracks({ + items: [ + { item: { type: "episode", id: "ep", name: "Podcast" } }, + { item: { type: "track", id: null, name: "Local file" } }, + { item: null }, + {}, + ], + }) + ).toEqual([]); + }); + + it("builds a track URL when external_urls is missing", () => { + const [track] = mapSpotifyPlaylistTracks({ + items: [{ item: { type: "track", id: "xyz", name: "Song" } }], + }); + expect(track!.songUrl).toBe("https://open.spotify.com/track/xyz"); + }); +}); + +describe("mapSpotifyPlaylistMeta", () => { + it("maps metadata and decodes the description", () => { + expect( + mapSpotifyPlaylistMeta( + { + id: "pl1", + name: "Roadtrip", + description: "Berkeley & SF", + images: [{ url: "https://cover", width: null, height: null }], + external_urls: { spotify: "https://open.spotify.com/playlist/pl1" }, + }, + "pl1" + ) + ).toEqual({ + id: "pl1", + name: "Roadtrip", + description: "Berkeley & SF", + coverArt: "https://cover", + url: "https://open.spotify.com/playlist/pl1", + }); + }); + + it("falls back to the configured id for the playlist URL", () => { + const meta = mapSpotifyPlaylistMeta({ name: "X" }, "fallback-id"); + expect(meta.url).toBe("https://open.spotify.com/playlist/fallback-id"); + }); +}); diff --git a/lib/spotify-playlists.ts b/lib/spotify-playlists.ts new file mode 100644 index 0000000..3a3f0db --- /dev/null +++ b/lib/spotify-playlists.ts @@ -0,0 +1,99 @@ +import { + formatSpotifyArtistNames, + pickAlbumArtFromSpotifyImages, + type SpotifyArtist, + type SpotifyImage, +} from "@/lib/spotify-now-playing-helpers"; + +export type PlaylistTrack = { + title: string; + artist: string; + albumArt: string; + songUrl: string; +}; + +export type SpotifyPlaylist = { + id: string; + name: string; + description: string; + coverArt: string; + url: string; + trackCount: number; + tracks: PlaylistTrack[]; +}; + +export type SpotifyPlaylistMetaPayload = { + id?: string; + name?: string; + description?: string | null; + images?: SpotifyImage[] | null; + external_urls?: { spotify?: string }; +}; + +/** + * Entry of the post-Feb-2026 `GET /playlists/{id}/items` endpoint — the track + * lives under `item` (the legacy `/tracks` endpoint used `track`). + */ +export type SpotifyPlaylistItemEntry = { + item?: { + type?: string; + id?: string | null; + name?: string; + artists?: SpotifyArtist[]; + album?: { images?: SpotifyImage[] }; + external_urls?: { spotify?: string }; + } | null; +}; + +export type SpotifyPlaylistItemsPayload = { + total?: number; + next?: string | null; + items?: SpotifyPlaylistItemEntry[]; +}; + +/** Spotify returns playlist descriptions HTML-escaped. */ +export function decodeSpotifyHtmlText(text: string): string { + return text + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&"); +} + +export function mapSpotifyPlaylistTracks( + payload: SpotifyPlaylistItemsPayload +): PlaylistTrack[] { + const out: PlaylistTrack[] = []; + for (const entry of payload.items ?? []) { + const item = entry?.item; + if (!item?.id || !item.name) continue; + // Playlists can also hold podcast episodes; only surface music tracks. + if (item.type && item.type !== "track") continue; + out.push({ + title: item.name, + artist: formatSpotifyArtistNames(item.artists), + albumArt: pickAlbumArtFromSpotifyImages(item.album?.images ?? undefined), + songUrl: + item.external_urls?.spotify ?? + `https://open.spotify.com/track/${encodeURIComponent(item.id)}`, + }); + } + return out; +} + +export function mapSpotifyPlaylistMeta( + payload: SpotifyPlaylistMetaPayload, + fallbackId: string +): Omit { + const id = payload.id ?? fallbackId; + return { + id, + name: payload.name ?? "", + description: decodeSpotifyHtmlText(payload.description ?? ""), + coverArt: pickAlbumArtFromSpotifyImages(payload.images ?? undefined), + url: + payload.external_urls?.spotify ?? + `https://open.spotify.com/playlist/${encodeURIComponent(id)}`, + }; +} diff --git a/next.config.ts b/next.config.ts index 795cfc3..a893c96 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,9 @@ const nextConfig: NextConfig = { images: { remotePatterns: [ { protocol: "https", hostname: "i.scdn.co" }, + // Playlist covers: custom uploads + Spotify's auto-generated mosaics + { protocol: "https", hostname: "*.spotifycdn.com" }, + { protocol: "https", hostname: "mosaic.scdn.co" }, { protocol: "https", hostname: "*.mzstatic.com" }, // Legacy listening_history/listening_stats rows (pre-Spotify, Last.fm era) { protocol: "https", hostname: "lastfm.freetls.fastly.net" }, From 5f5d7861a0e590aa95b70f1cdd43d59cc7066adf Mon Sep 17 00:00:00 2001 From: Kai Chen Date: Sat, 4 Jul 2026 21:03:16 -0700 Subject: [PATCH 2/2] feat: expand Music for Life playlists with detail pages, maps, and hover polish Add per-playlist detail routes and a fullscreen Mapbox viewer; fetch Spotify descriptions verbatim with period/places split on two lines and linked cities. Polish HoverTip bubbles (delayed fade/slide) and align place-link hovers with site list patterns. Update README and .env.example for new routes and Mapbox. Co-authored-by: Claude Co-authored-by: Cursor --- .env.example | 6 + README.md | 59 ++++++--- app/components/hover-link-hint.tsx | 6 +- app/components/hover-tip.tsx | 109 +++++++++++----- app/components/listening-card.tsx | 4 +- app/components/playlist-description.tsx | 128 +++++++++++++++++++ app/components/playlist-map.tsx | 104 +++++++++++++++ app/components/site-footer.tsx | 19 ++- app/components/subpage-enter.tsx | 4 + app/globals.css | 77 ++++++++++++ app/layout.tsx | 4 +- app/lib/spotify-playlists.ts | 160 +++++++++++++++++++++--- app/playlists/[id]/page.tsx | 145 +++++++++++++++++++++ app/playlists/map/layout.tsx | 5 + app/playlists/map/map-viewport-lock.tsx | 17 +++ app/playlists/map/page.tsx | 52 ++++++++ app/playlists/opengraph-image.tsx | 2 +- app/playlists/page.tsx | 103 ++++++++------- app/sitemap.ts | 6 + lib/mapbox-playlist-map.test.ts | 21 ++++ lib/mapbox-playlist-map.ts | 49 ++++++++ lib/spotify-playlists.test.ts | 93 ++++++++++++++ lib/spotify-playlists.ts | 106 +++++++++++++++- package-lock.json | 14 +++ package.json | 1 + 25 files changed, 1175 insertions(+), 119 deletions(-) create mode 100644 app/components/playlist-description.tsx create mode 100644 app/components/playlist-map.tsx create mode 100644 app/playlists/[id]/page.tsx create mode 100644 app/playlists/map/layout.tsx create mode 100644 app/playlists/map/map-viewport-lock.tsx create mode 100644 app/playlists/map/page.tsx create mode 100644 lib/mapbox-playlist-map.test.ts create mode 100644 lib/mapbox-playlist-map.ts diff --git a/.env.example b/.env.example index ef4f84f..de7ad74 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,12 @@ SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= SPOTIFY_REFRESH_TOKEN= +# --- Mapbox ------------------------------------------------------------------- +# Public token for `/playlists/map` (Mapbox GL JS). Create at mapbox.com → Access tokens. +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN= +# Optional: override basemap style (default Standard monochrome). Example: +# NEXT_PUBLIC_MAPBOX_MAP_STYLE=mapbox://styles/mapbox/outdoors-v12 + # --- GitHub ------------------------------------------------------------------ # Personal access token or fine-grained token with repo read access. # Powers /api/github/contributions and pinned repos (incl. star counts) on the site. diff --git a/README.md b/README.md index 8fdc422..128fe05 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende | `/` | First impression — identity, social links, Listening, Location, Projects | | `/about` | Deeper self — education, Focus, experience, volunteering | | `/projects` | Course Projects, Personal Projects, GitHub Activity | -| `/playlists` | Public Spotify playlists — covers, descriptions, tracks | +| `/playlists` | **Music for Life** — curated public Spotify playlists; index cards link to `/playlists/[id]` for tracks; place journals link cities to `/playlists/map` | | `/misc` | Watching, Remembrance, Things I Love | @@ -122,6 +122,8 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende | Accent | `#C4894F` | Links, hovers, editorial emphasis (`--accent`) | | Dark accent | `#D9A870` | Accent in `.dark` mode | +**Hover tips:** home social icons and card help bubbles use `[HoverTip](app/components/hover-tip.tsx)` — **70ms show delay**, fade + slide (`globals.css` `.hover-tip-bubble`, 220ms ease). Place-name links on playlist pages use row-style hover (background first, accent text via `group-hover`, 300ms). + --- @@ -203,7 +205,10 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal project cards, GitHub heatmap │ │ └── oxford-dul-2025/ # Oxford summer coursework portfolio (landing + [slug]) │ ├── misc/ # Watching, Remembrance, Things I Love + OG -│ ├── playlists/ # Public Spotify playlists (covers, tracks) + OG +│ ├── playlists/ # Music for Life — index, [id] detail, /map viewer + OG +│ │ ├── page.tsx # Curated playlist cards (Spotify metadata) +│ │ ├── [id]/page.tsx # Full track list per playlist +│ │ └── map/ # Fullscreen Mapbox GL (place journals) │ ├── api/ │ │ ├── spotify/now-playing/ │ │ ├── spotify/last-month-top/ @@ -212,17 +217,19 @@ kaichen.dev/ │ ├── components/ │ │ ├── oxford-dul/ # Portfolio cards, project sections, projects-split │ │ ├── nav.tsx # Bold labels; pill-shaped active underline -│ │ └── … # listening-card, weather-card, pinned-project-link, … +│ │ └── … # listening-card, hover-tip, playlist-description, playlist-map, … │ ├── hooks/use-now-playing.ts │ ├── lib/ │ │ ├── oxford-dul-projects.ts # Oxford portfolio metadata + 6 projects │ │ ├── course-projects.ts # Course Projects entries (Oxford + Notion notes) -│ │ ├── spotify-playlists.ts # Curated playlist IDs + server fetcher +│ │ ├── spotify-playlists.ts # CURATED_SPOTIFY_PLAYLISTS + server fetcher │ │ ├── og.tsx │ │ └── github-pinned.ts │ ├── sitemap.ts │ └── robots.ts ├── lib/ # Server helpers + Vitest tests +│ ├── spotify-playlists.ts # Types, description parsing, track mapping +│ ├── mapbox-playlist-map.ts # Mapbox Standard monochrome style helpers │ └── … ├── public/ │ ├── avatar.jpg @@ -246,7 +253,9 @@ kaichen.dev/ | `/projects` | **Course Projects** cards; **Personal Projects** grid; GitHub contribution calendar | | `/projects/oxford-dul-2025` | Oxford **Deep Unsupervised Learning** landing — overview, learning path, 6 project cards, academic record | | `/projects/oxford-dul-2025/{slug}` | Per-project detail — Problem, Dataset, Approach, Training, My Work, Evaluation, Results, Takeaway | -| `/playlists` | **Public Spotify playlists** — curated IDs in `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` (display order = array order); covers, descriptions and tracks fetched live (`revalidate = 3600`). Entry chip on the home Listening card | +| `/playlists` | **Music for Life** — curated playlist IDs in `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` (display order = array order). Descriptions live from Spotify; place journals split **period** / **places** on two lines; city names link to Mapbox via coords in config. Index → `/playlists/[id]` for tracks. `revalidate = 3600`. Home Listening chip: **music for life** | +| `/playlists/[id]` | Single playlist — cover, Spotify description, two-column track list, back chip to `/playlists` | +| `/playlists/map` | Fullscreen Mapbox GL viewer for a place (`?lng=&lat=&zoom=&label=`); compact footer, no page scroll | | `/misc` | **Watching** (dated news links), **Remembrance**, **Things I Love** (nested category inset cards) | @@ -277,6 +286,7 @@ Copy `[.env.example](.env.example)` to `.env.local`. **Never commit secrets.** | `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (Spotify listening history) | | `SUPABASE_SERVICE_ROLE_KEY` | **Server-only** — `listening_history` / `listening_stats` | | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify app + refresh token (`user-read-currently-playing`, `user-read-recently-played`) | +| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | Mapbox GL JS on `/playlists/map` (public token from mapbox.com) | | `GITHUB_TOKEN` | GitHub API — contributions + pinned repos | | `GITHUB_LOGIN` | Optional username (default `kaiiiichen`) | | `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | Optional error reporting | @@ -294,7 +304,8 @@ vercel env pull .env.vercel.check # gitignored — do not commit | Service | Use | | ------------------- | ------------------------------------------------------ | -| **Spotify Web API** | Now playing + recently played + public playlists | +| **Spotify Web API** | Now playing + recently played + public playlist metadata/tracks (`GET /playlists/{id}`, `/items`) | +| **Mapbox GL JS** | Place map viewer on `/playlists/map` (`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | | **GitHub GraphQL** | Contribution calendar + pinned repos (stars, archived) | | **Open-Meteo** | Berkeley weather (no key) | | **Supabase** | Optional listening history persistence | @@ -312,7 +323,7 @@ vercel env pull .env.vercel.check # gitignored — do not commit #### Testing -Vitest unit tests in `lib/*.test.ts` — weather parsing and Spotify helpers. +Vitest unit tests in `lib/*.test.ts` — weather parsing, Spotify playlist helpers, Mapbox style helpers. ```bash npm run test @@ -351,7 +362,7 @@ vercel --prod # after vercel link | Oxford portfolio | `[app/lib/oxford-dul-projects.ts](app/lib/oxford-dul-projects.ts)`, `app/projects/oxford-dul-2025/`, `app/components/oxford-dul/` | | Portfolio PNG assets | `public/portfolio/oxford-dul-2025/{slug}/` — see `[portfolio/oxford-dul-2025/README.md](portfolio/oxford-dul-2025/README.md)` | | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*` in `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | -| Spotify playlists | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` — `SPOTIFY_PLAYLIST_IDS` (order = display order) | +| Spotify playlists | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` — `CURATED_SPOTIFY_PLAYLISTS` (order = display order; `placeCoords` for Mapbox links) | | GitHub login / pins | `GITHUB_LOGIN`, `[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`, contributions route | | Supabase schema | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`, now-playing route | | Weather location | `[app/api/weather/route.ts](app/api/weather/route.ts)` | @@ -443,7 +454,7 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | `/` | 第一印象 —— 身份、社交链接、Listening、Location、Projects | | `/about` | 更深的自我 —— 教育、Focus、经历、志愿 | | `/projects` | Course Projects、Personal Projects、GitHub Activity | -| `/playlists` | 公开 Spotify 歌单——封面、简介、曲目 | +| `/playlists` | **Music for Life** — 精选公开 Spotify 歌单;索引卡片链到 `/playlists/[id]` 看曲目;地点日记的城市名链到 `/playlists/map` | | `/misc` | Watching、Remembrance、Things I Love | @@ -460,6 +471,8 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`. | 02 | `#C4894F` | 强调色(`--accent`) | | 03 | `#D9A870` | 暗色模式强调色 | +**Hover 气泡:** 首页社交图标与卡片帮助提示使用 `[HoverTip](app/components/hover-tip.tsx)` — **70ms 延迟**后淡入 + 上滑(`globals.css` `.hover-tip-bubble`)。歌单页地名链接采用行级 hover(先背景、再 accent 文字,300ms)。 + --- @@ -543,7 +556,10 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal 项目卡片、GitHub 热力图 │ │ └── oxford-dul-2025/ # 牛津暑校作品集(落地页 + [slug] 详情) │ ├── misc/ # Watching、Remembrance、Things I Love + OG -│ ├── playlists/ # 公开 Spotify 歌单(封面、曲目)+ OG +│ ├── playlists/ # Music for Life — 索引、[id] 详情、/map 地图 + OG +│ │ ├── page.tsx +│ │ ├── [id]/page.tsx +│ │ └── map/ │ ├── api/ │ │ ├── spotify/now-playing/ │ │ ├── spotify/last-month-top/ @@ -552,16 +568,19 @@ kaichen.dev/ │ ├── components/ │ │ ├── oxford-dul/ # 作品集卡片、详情区块、projects-split │ │ ├── nav.tsx # 粗体标签;圆角 active 下划线 -│ │ └── … +│ │ └── … # listening-card、hover-tip、playlist-description、playlist-map 等 │ ├── hooks/use-now-playing.ts │ ├── lib/ -│ │ ├── oxford-dul-projects.ts # 牛津作品集元数据 + 6 个项目 -│ │ ├── course-projects.ts # Course Projects 条目(牛津 + Notion 笔记) +│ │ ├── oxford-dul-projects.ts +│ │ ├── course-projects.ts +│ │ ├── spotify-playlists.ts # CURATED_SPOTIFY_PLAYLISTS + 服务端拉取 │ │ ├── og.tsx │ │ └── github-pinned.ts │ ├── sitemap.ts │ └── robots.ts -├── lib/ # 服务端 helper + Vitest 测试 +├── lib/ +│ ├── spotify-playlists.ts +│ ├── mapbox-playlist-map.ts │ └── … ├── public/ │ ├── avatar.jpg @@ -582,7 +601,9 @@ kaichen.dev/ | `/projects` | Course Projects 卡片;Personal Projects 网格;GitHub 贡献日历 | | `/projects/oxford-dul-2025` | 牛津 **Deep Unsupervised Learning** 落地页 | | `/projects/oxford-dul-2025/{slug}` | 单项目详情(Problem / Dataset / Training / Evaluation / Results 等) | -| `/playlists` | **公开 Spotify 歌单**——ID 列表在 `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`(数组顺序即展示顺序);封面、简介、曲目实时拉取(`revalidate = 3600`)。首页 Listening 卡片有入口 chip | +| `/playlists` | **Music for Life** — 配置在 `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`(数组顺序即展示顺序)。简介来自 Spotify;地点日记 **时间 / 地点** 分两行;城市名通过 `placeCoords` 链 Mapbox。索引 → `/playlists/[id]`。`revalidate = 3600`。首页 chip:**music for life** | +| `/playlists/[id]` | 单张歌单 — 封面、简介、双列曲目、返回 `/playlists` | +| `/playlists/map` | 全屏 Mapbox 地图(`?lng=&lat=&zoom=&label=`);紧凑页脚、禁止页面滚动 | | `/misc` | **Watching**、**Remembrance**、嵌套 **Things I Love** | @@ -613,6 +634,7 @@ kaichen.dev/ | `NEXT_PUBLIC_SUPABASE_URL` | Supabase 项目 URL(Spotify 听歌记录) | | `SUPABASE_SERVICE_ROLE_KEY` | **仅服务端** —— `listening_history` / `listening_stats` | | `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` / `SPOTIFY_REFRESH_TOKEN` | Spotify 应用 + refresh token(`user-read-currently-playing`、`user-read-recently-played`) | +| `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` | `/playlists/map` 的 Mapbox GL JS(mapbox.com 创建的 public token) | | `GITHUB_TOKEN` | GitHub API —— 贡献日历 + 置顶仓库 | | `GITHUB_LOGIN` | 可选用户名(默认 `kaiiiichen`) | | `NEXT_PUBLIC_SENTRY_DSN` / `SENTRY_DSN` | 可选错误上报 | @@ -630,7 +652,8 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交 | 服务 | 用途 | | ------------------- | -------------------------- | -| **Spotify Web API** | 正在播放 + 最近播放 | +| **Spotify Web API** | 正在播放 + 最近播放 + 公开歌单元数据/曲目(`GET /playlists/{id}`、`/items`) | +| **Mapbox GL JS** | `/playlists/map` 地点地图(`NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN`) | | **GitHub GraphQL** | 贡献日历 + 置顶仓库(star、archived) | | **Open-Meteo** | 伯克利天气(无需 key) | | **Supabase** | 可选听歌记录持久化 | @@ -648,7 +671,7 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交 #### 测试 -Vitest 单元测试位于 `lib/*.test.ts` —— 天气解析、Spotify helper。 +Vitest 单元测试位于 `lib/*.test.ts` —— 天气解析、Spotify 歌单 helper、Mapbox 样式 helper。 ```bash npm run test @@ -689,7 +712,7 @@ vercel --prod # 需先 vercel link | 牛津作品集 | `[app/lib/oxford-dul-projects.ts](app/lib/oxford-dul-projects.ts)`、`app/projects/oxford-dul-2025/`、`app/components/oxford-dul/` | | 作品集 PNG 资源 | `public/portfolio/oxford-dul-2025/{slug}/` —— 见 `[portfolio/oxford-dul-2025/README.md](portfolio/oxford-dul-2025/README.md)` | | Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*`,见 `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` | -| Spotify 歌单 | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)`——`SPOTIFY_PLAYLIST_IDS`(顺序即展示顺序) | +| Spotify 歌单 | `[app/lib/spotify-playlists.ts](app/lib/spotify-playlists.ts)` — `CURATED_SPOTIFY_PLAYLISTS`(顺序即展示;`placeCoords` 供 Mapbox 链接) | | GitHub 用户 / 置顶 | `GITHUB_LOGIN`、`[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`、contributions 路由 | | Supabase 表结构 | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`、now-playing 路由 | | 天气坐标 | `[app/api/weather/route.ts](app/api/weather/route.ts)` | diff --git a/app/components/hover-link-hint.tsx b/app/components/hover-link-hint.tsx index 50ab91a..15e12f6 100644 --- a/app/components/hover-link-hint.tsx +++ b/app/components/hover-link-hint.tsx @@ -8,7 +8,9 @@ export function linkDestinationLabel(href: string): string { if (href.startsWith("/projects")) return "Projects"; if (href.startsWith("/about")) return "About"; if (href.startsWith("/misc")) return "Misc"; - if (href.startsWith("/playlists")) return "Playlists"; + if (href.startsWith("/playlists/map")) return "Map"; + if (/^\/playlists\/[^/]+/.test(href)) return "Tracks"; + if (href.startsWith("/playlists")) return "Music for Life"; return "Site"; } @@ -18,6 +20,7 @@ export function linkDestinationLabel(href: string): string { if (host === "github.com") return "GitHub"; if (host === "open.spotify.com" || host === "spotify.com") return "Spotify"; + if (host === "mapbox.com") return "Mapbox"; if (host.endsWith("wikipedia.org")) return "Wikipedia"; if (host === "youtube.com" || host === "youtu.be") return "YouTube"; if (host.endsWith("bilibili.com")) return "Bilibili"; @@ -30,6 +33,7 @@ export function linkDestinationLabel(href: string): string { if (host === "wisprflow.ai") return "Wispr Flow"; if (host === "berkeleyside.org") return "Berkeleyside"; if (host.endsWith("notion.site")) return "Notion"; + if (host === "ginzasonypark.com") return "Ginza Sony Park"; const brand = host.split(".")[0]; return brand.charAt(0).toUpperCase() + brand.slice(1); diff --git a/app/components/hover-tip.tsx b/app/components/hover-tip.tsx index dbd2ad7..8f24911 100644 --- a/app/components/hover-tip.tsx +++ b/app/components/hover-tip.tsx @@ -45,12 +45,18 @@ function usePrefersHover() { } const TIP_BASE_CLASS = - "rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)] transition-all duration-150"; + "rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)]"; -function tipVisibilityClass(open: boolean) { - return open - ? "opacity-100 translate-y-0 pointer-events-auto" - : "opacity-0 translate-y-0.5 pointer-events-none"; +const SHOW_DELAY_MS = 70; + +function tipBubbleClass(open: boolean, placement: "top" | "bottom") { + return [ + "hover-tip-bubble block w-max max-w-[220px]", + placement === "bottom" ? "hover-tip-bubble--bottom" : "", + open ? "hover-tip-bubble--visible" : "", + ] + .filter(Boolean) + .join(" "); } function portalFixedStyle( @@ -111,9 +117,11 @@ export default function HoverTip({ const triggerRef = useRef(null); const tipRef = useRef(null); const hideTimerRef = useRef | null>(null); + const showTimerRef = useRef | null>(null); const prefersHover = usePrefersHover(); const [mounted, setMounted] = useState(false); const [open, setOpen] = useState(false); + const [displayed, setDisplayed] = useState(false); const [coords, setCoords] = useState(null); const touchToggle = tapToToggle || interactive; @@ -127,19 +135,50 @@ export default function HoverTip({ setCoords(el.getBoundingClientRect()); }, []); - const show = useCallback(() => { - if (hideTimerRef.current) { - clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - syncCoords(); - setOpen(true); - }, [syncCoords]); + const show = useCallback( + (immediate = false) => { + if (hideTimerRef.current) { + clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + syncCoords(); + if (displayed && !open) { + setOpen(true); + return; + } + if (open) return; + + if (showTimerRef.current) { + clearTimeout(showTimerRef.current); + showTimerRef.current = null; + } + showTimerRef.current = setTimeout( + () => { + setDisplayed(true); + requestAnimationFrame(() => setOpen(true)); + }, + immediate ? 0 : SHOW_DELAY_MS + ); + }, + [syncCoords, open, displayed] + ); const hide = useCallback(() => { + if (showTimerRef.current) { + clearTimeout(showTimerRef.current); + showTimerRef.current = null; + } setOpen(false); }, []); + const onBubbleTransitionEnd = useCallback( + (e: React.TransitionEvent) => { + if (e.propertyName !== "opacity" || open) return; + setDisplayed(false); + }, + [open] + ); + const scheduleHide = useCallback(() => { if (!interactive || !prefersHover) { hide(); @@ -149,9 +188,9 @@ export default function HoverTip({ }, [hide, interactive, prefersHover]); const toggle = useCallback(() => { - if (open) hide(); - else show(); - }, [open, hide, show]); + if (open || displayed) hide(); + else show(true); + }, [open, displayed, hide, show]); const onTriggerClick = useCallback( (e: React.MouseEvent) => { @@ -189,6 +228,7 @@ export default function HoverTip({ useEffect( () => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + if (showTimerRef.current) clearTimeout(showTimerRef.current); }, [] ); @@ -203,24 +243,32 @@ export default function HoverTip({ show() : undefined} onMouseLeave={prefersHover ? scheduleHide : undefined} - onFocus={show} + onFocus={() => show(true)} onBlur={prefersHover ? scheduleHide : undefined} onClick={onTriggerClick} > {children} - {createPortal( + {displayed && + createPortal( show(true) : undefined} onMouseLeave={interactive && prefersHover ? scheduleHide : undefined} > - {tip} + + {tip} + , document.body )} @@ -240,14 +288,15 @@ export default function HoverTip({ return ( {children} - - {tip} + + + {tip} + ); diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx index dd31078..ef594f9 100644 --- a/app/components/listening-card.tsx +++ b/app/components/listening-card.tsx @@ -92,8 +92,8 @@ export default function ListeningCard() {
- - playlists + + music for life
diff --git a/app/components/playlist-description.tsx b/app/components/playlist-description.tsx new file mode 100644 index 0000000..610d47e --- /dev/null +++ b/app/components/playlist-description.tsx @@ -0,0 +1,128 @@ +import { + parsePlaylistDescriptionCredit, + playlistPlaceMapUrl, + splitDescriptionByPlaceLabels, + splitPlaylistJournalLines, + type PlaylistPlaceCoords, +} from "@/lib/spotify-playlists"; + +const TEXT_CLASS = "text-zinc-400 dark:text-zinc-500"; +const CREDIT_CLASS = "text-zinc-400/90 dark:text-zinc-600"; + +const PLACE_LINK_WRAP = + "group/place inline rounded-sm px-0.5 -mx-0.5 " + + "hover:bg-zinc-50 dark:hover:bg-zinc-800/60 transition-all duration-150 no-underline"; + +const PLACE_LINK_TEXT = + "underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 " + + "group-hover/place:text-[#C4894F] group-hover/place:decoration-[#C4894F] " + + "dark:group-hover/place:text-[#D9A870] dark:group-hover/place:decoration-[#D9A870] " + + "transition-colors duration-300 ease-in-out"; + +const CREDIT_LINK_WRAP = + "group/credit inline rounded-sm px-0.5 -mx-0.5 " + + "hover:bg-zinc-50 dark:hover:bg-zinc-800/60 transition-all duration-150 no-underline"; + +const CREDIT_LINK_TEXT = + "underline underline-offset-2 decoration-zinc-300 dark:decoration-zinc-600 " + + "group-hover/credit:text-[#C4894F] group-hover/credit:decoration-[#C4894F] " + + "dark:group-hover/credit:text-[#D9A870] dark:group-hover/credit:decoration-[#D9A870] " + + "transition-colors duration-300 ease-in-out"; + +const textStyle = { + fontFamily: "'Nunito'", + fontWeight: 400, + fontSize: 13, + lineHeight: 1.6, +} as const; + +type PlaylistDescriptionProps = { + description: string; + placeCoords?: PlaylistPlaceCoords; + /** Set false when rendered inside a parent link (e.g. playlist index cards). */ + linkPlaces?: boolean; + linkCredit?: boolean; +}; + +function PlaceMapLink({ label, coords }: { label: string; coords: { lng: number; lat: number; zoom?: number } }) { + const mapUrl = playlistPlaceMapUrl(coords.lng, coords.lat, label, coords.zoom); + return ( + + {label} + + ); +} + +function renderPlaceSegments( + placesLine: string, + placeCoords: PlaylistPlaceCoords | undefined, + labels: string[], + linkPlaces: boolean +) { + return splitDescriptionByPlaceLabels(placesLine, labels).map((segment, index) => { + if (segment.kind === "text") { + return {segment.value}; + } + + const coords = placeCoords?.[segment.value]; + if (linkPlaces && coords) { + return ; + } + + return {segment.value}; + }); +} + +/** Spotify playlist description with optional Mapbox-linked place names. */ +export default function PlaylistDescription({ + description, + placeCoords, + linkPlaces = true, + linkCredit = true, +}: PlaylistDescriptionProps) { + const { body, credit } = parsePlaylistDescriptionCredit(description); + const labels = placeCoords ? Object.keys(placeCoords) : []; + const journalLines = splitPlaylistJournalLines(body); + + return ( +
+ {journalLines ? ( + <> +

+ {journalLines.periodLine} +

+

+ {renderPlaceSegments(journalLines.placesLine, placeCoords, labels, linkPlaces)} +

+ + ) : ( +

+ {renderPlaceSegments(body, placeCoords, labels, linkPlaces)} +

+ )} + + {credit ? ( +

+ (Credit: {credit.prefix} + {linkCredit ? ( + <> + {" "} + -{" "} + + {credit.href} + + + ) : ( + <> - {credit.href} + )} + ) +

+ ) : null} +
+ ); +} diff --git a/app/components/playlist-map.tsx b/app/components/playlist-map.tsx new file mode 100644 index 0000000..62ea8e8 --- /dev/null +++ b/app/components/playlist-map.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import mapboxgl from "mapbox-gl"; +import "mapbox-gl/dist/mapbox-gl.css"; +import { mapboxPlaylistMapOptions } from "@/lib/mapbox-playlist-map"; +import { useTheme } from "./theme-provider"; + +type PlaylistMapProps = { + lng: number; + lat: number; + zoom: number; + label: string; + /** Fill the parent — used on fullscreen `/playlists/map`. */ + fill?: boolean; +}; + +export default function PlaylistMap({ lng, lat, zoom, label, fill = false }: PlaylistMapProps) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const { resolvedTheme } = useTheme(); + + useEffect(() => { + const token = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; + const container = containerRef.current; + if (!token || !container) return; + + mapboxgl.accessToken = token; + + const { style, config, markerColor } = mapboxPlaylistMapOptions(resolvedTheme); + + const map = new mapboxgl.Map({ + container, + style, + ...(config ? { config } : {}), + center: [lng, lat], + zoom, + attributionControl: true, + }); + + mapRef.current = map; + + map.addControl(new mapboxgl.NavigationControl({ visualizePitch: false }), "top-right"); + + const marker = new mapboxgl.Marker({ color: markerColor }) + .setLngLat([lng, lat]) + .setPopup( + new mapboxgl.Popup({ + offset: 24, + closeButton: false, + className: "playlist-map-popup", + }).setText(label) + ) + .addTo(map); + + marker.togglePopup(); + + const resize = () => map.resize(); + map.on("load", resize); + + const observer = new ResizeObserver(resize); + observer.observe(container); + + return () => { + observer.disconnect(); + map.remove(); + mapRef.current = null; + }; + }, [lng, lat, zoom, label, resolvedTheme]); + + if (!process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN) { + const osm = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=${zoom}/${lat}/${lng}`; + return ( +
+

+ Set NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN in{" "} + .env.local to show the Mapbox map here. +

+ + Open in OpenStreetMap ↗ + +
+ ); + } + + return ( +
+ ); +} diff --git a/app/components/site-footer.tsx b/app/components/site-footer.tsx index 5578d10..ddbcc5c 100644 --- a/app/components/site-footer.tsx +++ b/app/components/site-footer.tsx @@ -1,9 +1,24 @@ +"use client"; + import Link from "next/link"; +import { usePathname } from "next/navigation"; export default function SiteFooter() { + const pathname = usePathname(); + const isMapPage = pathname.startsWith("/playlists/map"); + return ( -