Rebuild the UI on Next.js, and give search real metadata
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>
This commit is contained in:
1 parent
bc46fd80b3
commit
3b4b68daaf
34 files changed
+10655
-281
No files matched your search
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
|
||||
export function ArtistView({
|
||||
id,
|
||||
onAlbum,
|
||||
onQueued,
|
||||
}: {
|
||||
id: number;
|
||||
onAlbum: (id: number) => void;
|
||||
onQueued?: () => void;
|
||||
}) {
|
||||
const [data, setData] = React.useState<{
|
||||
artist: ArtistHeader;
|
||||
tracks: Track[];
|
||||
albums: AlbumCard[];
|
||||
} | null>(null);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
setData(null);
|
||||
setErr(null);
|
||||
api
|
||||
.artist(id)
|
||||
.then((d) => {
|
||||
if (!live) return;
|
||||
setData(d);
|
||||
void tint(d.artist.picture);
|
||||
})
|
||||
.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 artist: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
|
||||
const { artist, tracks, albums } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero
|
||||
kind="Artist"
|
||||
title={artist.name}
|
||||
art={artist.picture}
|
||||
round
|
||||
facts={[
|
||||
artist.nb_fan ? `${artist.nb_fan.toLocaleString()} fans` : null,
|
||||
artist.nb_album ? `${artist.nb_album} releases` : null,
|
||||
]}
|
||||
/>
|
||||
|
||||
<SectionHead>Top tracks</SectionHead>
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
showArtist={false}
|
||||
onAlbum={onAlbum}
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{albums.length > 0 && (
|
||||
<>
|
||||
<SectionHead>Releases</SectionHead>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(136px,1fr))] gap-3.5">
|
||||
{albums.map((a, i) => (
|
||||
<motion.button
|
||||
key={a.album_id}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.025, 0.5) }}
|
||||
whileHover={{ y: -4 }}
|
||||
onClick={() => onAlbum(a.album_id)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={a.cover ?? ""}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="mb-2 aspect-square w-full rounded-[13px] bg-faint object-cover
|
||||
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
<div className="text-[13.5px] font-medium clamp1">{a.title}</div>
|
||||
<div className="text-xs text-dim clamp1">
|
||||
{[a.year, a.record_type].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHead({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="mt-6 mb-3 text-xs font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user