diff --git a/README.md b/README.md index 5b5b46d..4a54c69 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,21 @@ Personal website of Kai Chen — a living, dynamic digital identity system. ### Backend & APIs - **Hosting**: Vercel (serverless) -- **Database**: Supabase (PostgreSQL) — guestbook +- **Database**: Supabase (PostgreSQL) + - `guestbook` — public guestbook + - `listening_history` — every Spotify fetch recorded (proxy for listening duration) + - `listening_stats` — unique tracks with play_count, used for Gallery ranking - **Spotify**: Vercel serverless function (`/api/spotify/now-playing`) - - Fetches directly from Spotify API with token refresh - - CDN-cached for 10s (`s-maxage=10`) — all visitors share one Spotify API call per interval + - Token refresh with in-memory cache + - Writes to Supabase on every fetch when isPlaying is true - **GitHub API**: GraphQL — contribution graph & last commit - **Weather API**: Open-Meteo (free, no API key required) +### Mobile & Responsive +- **Layout**: Single-column on mobile (`grid-cols-1`), two-column on desktop (`md:grid-cols-2`) +- **Navigation**: Desktop section nav (`hidden md:flex`) replaced by hamburger menu on mobile +- **Mobile nav drawer**: Slides in from the left, backdrop blur overlay, body scroll locked while open, staggered link entrance animation (40ms delay per item), instant exit + ### External Integrations - **Spotify Web API** — real-time now playing, album art, playback progress - **GitHub GraphQL API** — contribution graph (past year), last commit info @@ -37,11 +45,12 @@ Browser (kaichen.dev) └── Vercel (Next.js) ├── /api/github/contributions → GitHub GraphQL API ├── /api/weather → Open-Meteo API - ├── /api/guestbook → Supabase (PostgreSQL) - └── /api/spotify/now-playing → Spotify API (CDN-cached 10s) + ├── /api/guestbook → Supabase (guestbook table) + ├── /api/spotify/now-playing → Spotify API → Supabase (listening_history + listening_stats) + └── /api/spotify/recent-albums → Supabase (listening_stats, sorted by play_count) ``` -Key design decision: `/api/spotify/now-playing` is cached at the CDN edge (`s-maxage=10`). All visitors share a single cached response, keeping Spotify API calls constant regardless of traffic. +Key design decision: every Spotify fetch is recorded in Supabase. listening_history approximates listening duration (each record = one 10s polling interval). listening_stats powers the Gallery, ranked by play_count. --- @@ -53,6 +62,7 @@ Key design decision: `/api/spotify/now-playing` is cached at the CDN edge (`s-ma | `/about` | Background, experience, contact | | `/projects` | Projects with active/archived status tags | | `/guestbook` | Public guestbook powered by Supabase | +| `/gallery` | Scroll-driven album gallery, ranked by personal play count from Supabase | --- @@ -70,6 +80,7 @@ SPOTIFY_CLIENT_SECRET= SPOTIFY_REFRESH_TOKEN= NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= GITHUB_TOKEN= ``` @@ -97,11 +108,14 @@ npm run dev kaichen-dev/ ├── app/ │ ├── page.tsx # Home page +│ ├── gallery/page.tsx # Scroll-driven album gallery │ ├── about/page.tsx # About page │ ├── projects/page.tsx # Projects page │ ├── guestbook/page.tsx # Guestbook page │ ├── api/ -│ │ ├── spotify/ # Spotify API routes (fallback) +│ │ ├── spotify/ +│ │ │ ├── now-playing/ # Fetches Spotify + writes to Supabase +│ │ │ └── recent-albums/ # Reads from listening_stats │ │ ├── github/ # GitHub contributions & last commit │ │ ├── weather/ # Weather data from Open-Meteo │ │ └── guestbook/ # Guestbook CRUD via Supabase @@ -110,10 +124,12 @@ kaichen-dev/ │ ├── SpotifyBar.tsx # Global bottom Spotify bar (non-home pages) │ ├── GitHubActivity.tsx # Contribution graph + last commit │ ├── ThemeToggle.tsx # Dark/light mode toggle +│ ├── mobile-nav.tsx # Hamburger + slide-in drawer (mobile only) +│ ├── home-nav-client.tsx # Section anchor nav (desktop only, hidden md:flex) │ └── ... ├── lib/ -│ ├── spotify.ts # Spotify token refresh & API helpers -│ └── supabase.ts # Supabase client +│ ├── spotify.ts # Token refresh, Supabase write on fetch +│ └── supabase.ts # Supabase anon client ├── app/hooks/ │ └── use-now-playing.ts # Spotify polling hook (10s interval) ├── .env.example # Environment variable template diff --git a/app/api/spotify/recent-albums/route.ts b/app/api/spotify/recent-albums/route.ts index 3c14637..7224c0b 100644 --- a/app/api/spotify/recent-albums/route.ts +++ b/app/api/spotify/recent-albums/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; export type RecentAlbum = { albumId: string; @@ -9,54 +10,34 @@ export type RecentAlbum = { }; export async function GET() { - const basic = Buffer.from( - `${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}` - ).toString("base64"); - - const tokenRes = await fetch("https://accounts.spotify.com/api/token", { - method: "POST", - headers: { - Authorization: `Basic ${basic}`, - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: process.env.SPOTIFY_REFRESH_TOKEN!, - }), - }); - - if (!tokenRes.ok) { - return NextResponse.json({ error: "token error" }, { status: 500 }); - } - - const { access_token } = await tokenRes.json(); - - const res = await fetch( - "https://api.spotify.com/v1/me/player/recently-played?limit=50", - { headers: { Authorization: `Bearer ${access_token}` } } + const db = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! ); - if (!res.ok) { - return NextResponse.json({ error: "spotify error" }, { status: 502 }); + const { data, error } = await db + .from("listening_stats") + .select("track_id, track_name, artist_name, album_id, album_name, album_art, song_url, play_count") + .order("play_count", { ascending: false }) + .limit(200); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); } - const data = await res.json(); + // Deduplicate by album_id — keep the track with highest play_count per album const seen = new Set(); const albums: RecentAlbum[] = []; - for (const item of data.items ?? []) { - const album = item.track?.album; - if (!album) continue; - if (seen.has(album.id)) continue; - seen.add(album.id); + for (const row of data ?? []) { + if (seen.has(row.album_id)) continue; + seen.add(row.album_id); albums.push({ - albumId: album.id, - albumName: album.name, - artistName: item.track.artists - .map((a: { name: string }) => a.name) - .join(", "), - albumArt: album.images[0]?.url ?? "", - albumUrl: album.external_urls?.spotify ?? "", + albumId: row.album_id, + albumName: row.album_name, + artistName: row.artist_name, + albumArt: row.album_art, + albumUrl: row.song_url, }); if (albums.length >= 50) break; } diff --git a/lib/spotify.ts b/lib/spotify.ts index d125382..ded21aa 100644 --- a/lib/spotify.ts +++ b/lib/spotify.ts @@ -1,7 +1,16 @@ +import { createClient } from "@supabase/supabase-js"; + const TOKEN_URL = "https://accounts.spotify.com/api/token"; const NOW_PLAYING_URL = "https://api.spotify.com/v1/me/player/currently-playing"; +function getServiceSupabase() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); +} + export type RecentTrack = { title: string; artist: string; @@ -25,7 +34,13 @@ export type NowPlayingResult = let currentTrack: RecentTrack | null = null; let previousTrack: RecentTrack | null = null; +// In-memory token cache — avoids redundant refresh calls within the same instance +let cachedToken: string | null = null; +let tokenExpiresAt = 0; + async function getAccessToken(): Promise { + if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken; + const clientId = process.env.SPOTIFY_CLIENT_ID; const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; const refreshToken = process.env.SPOTIFY_REFRESH_TOKEN; @@ -34,8 +49,6 @@ async function getAccessToken(): Promise { const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); - // Cache token in Next.js Data Cache for 50 minutes (tokens last 1 hour) - // This survives across serverless cold starts on Vercel const res = await fetch(TOKEN_URL, { method: "POST", headers: { @@ -46,13 +59,16 @@ async function getAccessToken(): Promise { grant_type: "refresh_token", refresh_token: refreshToken, }), - next: { revalidate: 3000 }, // 50 minutes + cache: "no-store", }); if (!res.ok) return null; const data = await res.json(); - return (data.access_token as string) ?? null; + cachedToken = (data.access_token as string) ?? null; + tokenExpiresAt = Date.now() + (data.expires_in ?? 3600) * 1000; + + return cachedToken; } export async function getNowPlaying(): Promise { @@ -100,6 +116,47 @@ export async function getNowPlaying(): Promise { return { isPlaying: false, recentTrack: currentTrack ?? previousTrack ?? undefined }; } + // Record to Supabase (fire-and-forget, don't block the response) + const trackId: string = data.item.id; + const albumId: string = data.item.album.id; + const albumName: string = data.item.album.name; + + void (async () => { + try { + const db = getServiceSupabase(); + await db.from("listening_history").insert({ + track_id: trackId, + track_name: data.item.name, + artist_name: incoming.artist, + album_id: albumId, + album_name: albumName, + album_art: incoming.albumArt, + song_url: incoming.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: data.item.name, + artist_name: incoming.artist, + album_id: albumId, + album_name: albumName, + album_art: incoming.albumArt, + song_url: incoming.songUrl, + play_count: (existing?.play_count ?? 0) + 1, + last_played_at: new Date().toISOString(), + }, + { onConflict: "track_id" } + ); + } catch { + // Non-critical — silently ignore Supabase errors + } + })(); + return { isPlaying: true, title: data.item.name,