Music showcase for Bohns — albums and tracks streamed from Supabase, rebuilt on Next.js (App Router) with a full redesign of the Home and Album pages.
- Next.js 16 (App Router, TypeScript, CSS Modules)
- Supabase — public content (
albums,track_overviewview,trackslyrics) plus Google sign-in and per-user likes (@supabase/ssr) - lucide-react icons
- Fonts: Gloock (display) + Space Grotesk via
next/font
- The site header renders with
mix-blend-mode: color-dodgeover the dark mauve background (#11090C) — its soft-grey content composites to pink. - Only the home carries that full header. Every other page pins the Bohns
signature top-left (
<SiteLogo />, fixed, the counterpart to the account button top-right) and closes with the same brand · menu · social row as a<Footer />. Both composite withcolor-dodgelike the header. - Each album has a color palette theme (
light/accent/deep) applied through CSS custom properties. Palettes are hardcoded insrc/lib/palettes.tsfor now (no Supabase model change, step 1). - Track durations are not stored in the database; they resolve client-side
from audio metadata (
preload="metadata"). - Waveforms are precomputed and stored, not decoded in the browser
(
versions.waveform_peaks, 512 values normalised 0–1). New uploads compute them client-side; existing rows were filled byscripts/backfill-version-waveforms.mjs(needsffmpeg). Same reasoning as the stored durations — seesupabase/migrations/20260919010000_waveform_peaks.sql. Peaks are read only bygetTrack, never by the home or album queries, so those payloads stay small. - Track and album copy comes in two fields:
description, a short blurb for the strips and rows (kept under 200 characters, clamped to two lines), andstory, long-form markdown shown on the track and album pages. Both are bilingual — a_frcolumn alongside each, resolved bylocalized()insrc/lib/i18n/config.ts, falling back to English when a French value is missing.
/— artist header (brand, bio, social links) then two tabbed sections: a Tracks section of standalone tracks (the cover doubles as the play/pause control) under Popular / Latests tabs, and an Albums section of album cards filtered by genre. Popularity ranks a track bylikes × 10 + plays ÷ 10(play totals come from thetrack_play_countsview)/albums/[id]— one grid on desktop: the album's cover heads a 300px column with the rail of other albums running down it, beside the album's header (name, meta, description, tiles) over its tracklist and long-form Notes.AlbumDetailCardand its hero dissolve into that grid withdisplay: contents, so the same markup collapses to a plain column on mobile, where the rail becomes a horizontal strip below the album's content/tracks/[id]— a track's own page, laid out like the album page: a left column of album context (name, N songs · M min, the album's tracklist with the viewed track marked) beside a header built like the album header (the album's vinyl, the track name, release date · status, description, and tiles carrying the like and lyrics actions), then a song visualizer — play button, waveform and duration in the song's colour — and the track's long-form notes. Reached by clicking a track's name anywhere it appears/artist/albums— artist-only: rank the albums shown in the home Albums section. Reached from the account menu's Artist group (only rendered for artist members). Moves are staged locally and saved as one full ranking via thereorder_albumsRPC, which re-checks membership before writing; the order lives inalbums.position(seesupabase/migrations/20260716000000_album_position.sql) and albums without one sort last, newest first
Playing a track queues its whole album into a global player bar: waveform
timeline (wavesurfer.js) recolored per album palette, play/pause,
next/previous, repeat (all by default), scrubbing, and MediaSession
metadata + handlers for lock-screen controls on iOS/Android. Playback
survives page navigation (the player lives above the router).
- Sign in with Google from the account button (top-right on every page).
OAuth runs through Supabase Auth: the browser client starts the flow, Google
redirects back to
/auth/callback, and the route handler exchanges the code for a cookie session.middleware.tskeeps the session fresh. - Likes are per-user and persisted in the
track_likestable. The heart on each track toggles a row via the browser client (writes are guarded by row-level security), with an optimistic count and rollback on failure. The public catalog stays statically cached (ISR) — only the like state is resolved client-side, so pages don't become per-user dynamic. /profileshows the signed-in user and a sign-out control;/likeslists the tracks the user has liked, most recent first.- Artist members (rows in
artist_members) additionally get inline album editing and the/artist/albumsordering page.
Supabase clients live in src/lib/supabase/: anon.ts (public read-only
content), client.ts (browser, auth + likes) and server.ts (cookie-bound, for
the callback route and the protected pages). The track_likes schema + RLS are
in supabase/migrations/20260710_track_likes.sql.
The hosted project must have Google enabled as an auth provider and
<site>/auth/callbackin its redirect allow-list. No extra environment variables are needed beyond the two below.
cp .env.example .env.local # fill in the Supabase URL + anon key
npm install
npm run devRequired environment variables (also needed on Vercel):
| Variable | Description |
|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase anonymous key |
This project is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License.
You are free to share, adapt, and build upon this work for non-commercial purposes only, as long as you provide appropriate credit.