diff --git a/.env.example b/.env.example index a564697..6d4a16e 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,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 2f7f9c8..104af8f 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` | **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 | @@ -121,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). + --- @@ -202,6 +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/ # 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/ @@ -210,16 +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_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 @@ -243,6 +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` | **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) | @@ -272,7 +285,8 @@ 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`, `user-top-read`) | +| `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 | @@ -290,7 +304,8 @@ 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 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 | @@ -308,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 @@ -347,6 +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)` — `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)` | @@ -438,6 +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` | **Music for Life** — 精选公开 Spotify 歌单;索引卡片链到 `/playlists/[id]` 看曲目;地点日记的城市名链到 `/playlists/map` | | `/misc` | Watching、Remembrance、Things I Love | @@ -454,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)。 + --- @@ -537,6 +556,10 @@ kaichen.dev/ │ │ ├── page.tsx # Course + Personal 项目卡片、GitHub 热力图 │ │ └── oxford-dul-2025/ # 牛津暑校作品集(落地页 + [slug] 详情) │ ├── misc/ # Watching、Remembrance、Things I Love + OG +│ ├── playlists/ # Music for Life — 索引、[id] 详情、/map 地图 + OG +│ │ ├── page.tsx +│ │ ├── [id]/page.tsx +│ │ └── map/ │ ├── api/ │ │ ├── spotify/now-playing/ │ │ ├── spotify/last-month-top/ @@ -545,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 @@ -575,6 +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` | **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** | @@ -604,7 +633,8 @@ 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`、`user-top-read`) | +| `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` | 可选错误上报 | @@ -622,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** | 可选听歌记录持久化 | @@ -640,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 @@ -681,6 +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)` — `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 365df11..15e12f6 100644 --- a/app/components/hover-link-hint.tsx +++ b/app/components/hover-link-hint.tsx @@ -8,6 +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/map")) return "Map"; + if (/^\/playlists\/[^/]+/.test(href)) return "Tracks"; + if (href.startsWith("/playlists")) return "Music for Life"; return "Site"; } @@ -17,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"; @@ -29,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 3847bbf..ef594f9 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} -
+
+ + 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 ( -