Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

---

Expand All @@ -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 |

---

Expand All @@ -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=
```

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
61 changes: 21 additions & 40 deletions app/api/spotify/recent-albums/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

export type RecentAlbum = {
albumId: string;
Expand All @@ -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<string>();
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;
}
Expand Down
65 changes: 61 additions & 4 deletions lib/spotify.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string | null> {
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;
Expand All @@ -34,8 +49,6 @@ async function getAccessToken(): Promise<string | null> {

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: {
Expand All @@ -46,13 +59,16 @@ async function getAccessToken(): Promise<string | null> {
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<NowPlayingResult> {
Expand Down Expand Up @@ -100,6 +116,47 @@ export async function getNowPlaying(): Promise<NowPlayingResult> {
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,
Expand Down
Loading