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,170 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Play, Pause } from "lucide-react";
|
||||
import { api, mmss, type Track } from "@/lib/api";
|
||||
import { usePlayer } from "@/components/player";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TrackRow({
|
||||
track,
|
||||
index,
|
||||
showNumber,
|
||||
showArtist = true,
|
||||
showAlbum = true,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
onQueued,
|
||||
}: {
|
||||
track: Track;
|
||||
index: number;
|
||||
showNumber?: boolean;
|
||||
showArtist?: boolean;
|
||||
showAlbum?: boolean;
|
||||
onAlbum?: (id: number) => void;
|
||||
onArtist?: (id: number) => void;
|
||||
onQueued?: () => void;
|
||||
}) {
|
||||
const { playing, toggle } = usePlayer();
|
||||
const toast = useToast();
|
||||
const [state, setState] = React.useState<"idle" | "busy" | "done">("idle");
|
||||
|
||||
const isPlaying = playing === track.deezer_id;
|
||||
|
||||
const queue = async () => {
|
||||
setState("busy");
|
||||
try {
|
||||
await api.request(track);
|
||||
setState("done");
|
||||
toast(`Queued ${track.artist} — ${track.title}`);
|
||||
onQueued?.();
|
||||
} catch (e) {
|
||||
setState("idle");
|
||||
toast(`Could not queue: ${(e as Error).message}`, true);
|
||||
}
|
||||
};
|
||||
|
||||
const facts: React.ReactNode[] = [];
|
||||
if (showAlbum && track.album)
|
||||
facts.push(
|
||||
track.album_id && onAlbum ? (
|
||||
<button
|
||||
key="al"
|
||||
onClick={() => onAlbum(track.album_id!)}
|
||||
className="hover:text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{track.album}
|
||||
</button>
|
||||
) : (
|
||||
<span key="al">{track.album}</span>
|
||||
),
|
||||
);
|
||||
if (track.year) facts.push(<span key="yr">{track.year}</span>);
|
||||
if (track.genre) facts.push(<span key="gn">{track.genre}</span>);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||
whileHover={{ y: -1 }}
|
||||
className={cn(
|
||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
||||
isPlaying && "border-accent-line bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
{showNumber && (
|
||||
<div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums">
|
||||
{track.track ?? ""}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => toggle(track)}
|
||||
disabled={!track.preview}
|
||||
aria-label={track.preview ? `Preview ${track.title}` : "No preview available"}
|
||||
className={cn(
|
||||
"relative w-[50px] h-[50px] shrink-0 rounded-[9px] overflow-hidden bg-faint group",
|
||||
track.preview ? "cursor-pointer" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
{track.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={track.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
{track.preview && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 grid place-items-center bg-black/50 text-white",
|
||||
"transition-opacity duration-200",
|
||||
isPlaying ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="w-[18px] h-[18px] fill-current" />
|
||||
) : (
|
||||
<Play className="w-[18px] h-[18px] fill-current" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1">
|
||||
{track.title}
|
||||
{track.explicit && (
|
||||
<span className="ml-1.5 align-[1px] inline-block rounded-[3px] border border-edge bg-faint px-1 text-[9px] font-bold text-dim">
|
||||
E
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||
{showArtist && track.artist_id && onArtist ? (
|
||||
<button
|
||||
onClick={() => onArtist(track.artist_id!)}
|
||||
className="hover:text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{track.artist}
|
||||
</button>
|
||||
) : showArtist ? (
|
||||
<span>{track.artist}</span>
|
||||
) : null}
|
||||
{facts.map((f, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{(showArtist || i > 0) && <span className="mx-1.5 opacity-50">·</span>}
|
||||
{f}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums">
|
||||
{track.length ? mmss(track.length) : ""}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="pill"
|
||||
onClick={queue}
|
||||
disabled={state !== "idle"}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
state === "done" && "border-ok/50 text-ok bg-ok/10 hover:bg-ok/10 hover:text-ok hover:border-ok/50",
|
||||
)}
|
||||
>
|
||||
{state === "idle" ? "Get" : state === "busy" ? "…" : "Queued"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user