The old page was hand-written HTML/CSS/JS. That was fine for a search box and
a list, but the roadmap here is multi-user, an admin dashboard with usage
graphs, and an import-review queue — all of which get painful in vanilla DOM
code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
uvicorn would add a Node runtime, a second process to supervise, and a second
thing that can be down while the other is up. There is no use for SSR — every
view is behind auth and renders from live JSON — so the deployment stays one
container, one process, one healthcheck. Image is 60MB; Node only exists in
the build stage.
Search now carries what a music UI needs:
- year and genre, grafted on from /album/{id} concurrently, deduplicated by
album and cached forever since a release date is immutable
- 30-second previews, so you can hear a track before committing a Soulseek
search to it. Not a library player — that is Navidrome's job.
- autocomplete over songs/albums/artists, debounced and aborted per keystroke
so a slow response to "rad" cannot land after "radiohead"
- album and artist pages, with `Get all` to queue a release in one request
- the accent colour is sampled from the artwork, so the palette follows the
music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
with no image proxy.
/api/album/{id} deliberately calls Deezer twice: the track list embedded in
the album response has no track_position, and using the list index instead
misnumbers every multi-disc release — verified against the White Album, where
disc 2 track 1 would have been filed as 18.
Schema is multi-user ready ahead of the feature: users table with roles and
quotas, requested_by on jobs, and the first login becomes admin. Auth is still
the single-user GitHub allowlist.
CSP survives all of this intact. Next emits inline hydration scripts, which
script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
computed at startup from the built page — a static export never varies between
requests, so hashes need no maintenance and no HTML rewriting. The one
concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
pre-hydration state into style="" and without it every element flashes in.
That permits style attributes only. img-src/media-src now name Deezer's CDN
explicitly instead of allowing https: wholesale, closing an exfiltration
channel that survived connect-src 'self'. /_next/* sits behind the session
too — there is no local login page to style, so the bundle need not be public.
Verified: unauthenticated /→302 and every /api/*→401 including the four new
endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
start, and the production image hydrates with zero CSP violations.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
3.1 KiB
TypeScript
117 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { motion } from "framer-motion";
|
|
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
|
import { tint } from "@/lib/colour";
|
|
import { Hero } from "@/components/hero";
|
|
import { TrackRow } from "@/components/track-row";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/components/toast";
|
|
import { Skeletons } from "@/components/skeletons";
|
|
|
|
export function AlbumView({
|
|
id,
|
|
onArtist,
|
|
onQueued,
|
|
}: {
|
|
id: number;
|
|
onArtist: (id: number) => void;
|
|
onQueued?: () => void;
|
|
}) {
|
|
const toast = useToast();
|
|
const [data, setData] = React.useState<{ album: AlbumHeader; tracks: Track[] } | null>(null);
|
|
const [err, setErr] = React.useState<string | null>(null);
|
|
const [batch, setBatch] = React.useState<"idle" | "busy" | "done">("idle");
|
|
const [queued, setQueued] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
let live = true;
|
|
setData(null);
|
|
setErr(null);
|
|
setBatch("idle");
|
|
api
|
|
.album(id)
|
|
.then((d) => {
|
|
if (!live) return;
|
|
setData(d);
|
|
void tint(d.album.cover_big ?? d.album.cover);
|
|
})
|
|
.catch((e) => live && setErr((e as Error).message));
|
|
return () => {
|
|
live = false;
|
|
};
|
|
}, [id]);
|
|
|
|
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
|
if (!data) return <Skeletons n={8} />;
|
|
|
|
const { album, tracks } = data;
|
|
|
|
const getAll = async () => {
|
|
setBatch("busy");
|
|
try {
|
|
const d = await api.requestBatch(tracks);
|
|
setQueued(d.queued);
|
|
setBatch("done");
|
|
toast(`Queued ${d.queued} tracks from ${album.title}`);
|
|
onQueued?.();
|
|
} catch (e) {
|
|
setBatch("idle");
|
|
toast(`Could not queue album: ${(e as Error).message}`, true);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Hero
|
|
kind={album.record_type ?? "Album"}
|
|
title={album.title}
|
|
art={album.cover_big ?? album.cover}
|
|
by={
|
|
album.artist_id ? (
|
|
<button
|
|
onClick={() => onArtist(album.artist_id!)}
|
|
className="border-b border-edge-hi hover:text-accent hover:border-accent-line cursor-pointer"
|
|
>
|
|
{album.artist}
|
|
</button>
|
|
) : (
|
|
album.artist
|
|
)
|
|
}
|
|
facts={[
|
|
album.year,
|
|
album.genres?.[0],
|
|
album.label,
|
|
album.nb_tracks ? `${album.nb_tracks} tracks` : null,
|
|
album.duration ? mmss(album.duration * 1000) : null,
|
|
]}
|
|
actions={
|
|
<Button onClick={getAll} disabled={batch !== "idle"}>
|
|
{batch === "idle"
|
|
? `Get all ${tracks.length} tracks`
|
|
: batch === "busy"
|
|
? "Queueing…"
|
|
: `Queued ${queued}`}
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<motion.div layout className="flex flex-col gap-[7px]">
|
|
{tracks.map((t, i) => (
|
|
<TrackRow
|
|
key={t.deezer_id}
|
|
track={t}
|
|
index={i}
|
|
showNumber
|
|
showArtist={false}
|
|
showAlbum={false}
|
|
onQueued={onQueued}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</>
|
|
);
|
|
}
|