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,148 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { api, type Job } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
jobs: Job[];
|
||||
counts: Record<string, number>;
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const PILL: Record<Job["state"], string> = {
|
||||
imported: "text-ok border-ok/40 bg-ok/10",
|
||||
failed: "text-err border-err/40 bg-err/10",
|
||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||
searching: "text-warn border-warn/40 bg-warn/10",
|
||||
queued: "text-warn border-warn/40 bg-warn/10",
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const total = active + done;
|
||||
|
||||
const stats: [string, number, string][] = [
|
||||
["In flight", active, "text-accent"],
|
||||
["Imported", done, "text-ok"],
|
||||
["Failed", failed, "text-err"],
|
||||
["Waiting", pending, ""],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{stats.map(([label, n, colour]) => (
|
||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||
<motion.b
|
||||
key={n}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn("block text-2xl font-bold leading-tight tracking-[-0.7px]", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 4 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||
>
|
||||
<motion.i
|
||||
className="block h-1 rounded-full bg-accent"
|
||||
animate={{ width: `${(done / total) * 100}%` }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<AnimatePresence initial={false}>
|
||||
{jobs.map((j) => (
|
||||
<motion.div
|
||||
key={j.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||
>
|
||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={j.cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14.5px] font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-[12.5px] text-dim clamp1">
|
||||
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{j.state === "failed" && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await api.retry(j.id);
|
||||
toast("Requeued");
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
||||
"text-[11px] font-semibold capitalize",
|
||||
PILL[j.state] ?? "text-dim border-edge",
|
||||
)}
|
||||
>
|
||||
{(j.state === "searching" || j.state === "downloading") && (
|
||||
<motion.span
|
||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||
animate={{ opacity: [1, 0.25, 1] }}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{j.state}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user