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,88 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Album/artist header. The artwork itself, blown up and blurred, is the
|
||||
* panel's own backdrop — cheaper and more faithful than trying to synthesise
|
||||
* a gradient from the extracted accent, and it reads as the record rather
|
||||
* than as a themed box.
|
||||
*/
|
||||
export function Hero({
|
||||
kind,
|
||||
title,
|
||||
art,
|
||||
round,
|
||||
by,
|
||||
facts,
|
||||
actions,
|
||||
}: {
|
||||
kind: string;
|
||||
title: string;
|
||||
art: string | null;
|
||||
round?: boolean;
|
||||
by?: React.ReactNode;
|
||||
facts?: (string | null | undefined)[];
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||
>
|
||||
{art && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={art}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="absolute inset-0 z-0 h-full w-full scale-[1.35] object-cover
|
||||
opacity-40 blur-[46px] saturate-[1.8]"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 z-[1] bg-gradient-to-r from-[var(--scrim)] to-[var(--scrim-2)]" />
|
||||
|
||||
{art && (
|
||||
<motion.img
|
||||
layoutId={`art-${title}`}
|
||||
src={art}
|
||||
alt=""
|
||||
className={cn(
|
||||
"relative z-[2] w-[132px] h-[132px] sm:w-[150px] sm:h-[150px] shrink-0 object-cover",
|
||||
"bg-faint shadow-[0_14px_40px_rgb(0_0_0/0.5)]",
|
||||
round ? "rounded-full" : "rounded-2xl",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center">
|
||||
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-[1px] text-accent">
|
||||
{kind}
|
||||
</div>
|
||||
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
|
||||
{title}
|
||||
</h2>
|
||||
{by && <div className="mb-2.5 text-[15px] font-semibold">{by}</div>}
|
||||
{facts && (
|
||||
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
|
||||
{facts.filter(Boolean).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="glass-hi rounded-full px-2.5 py-0.5 text-[11.5px] text-dim"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{actions && (
|
||||
<div className="flex flex-wrap justify-center sm:justify-start gap-2.5">{actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user