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>
234 lines
8.1 KiB
TypeScript
234 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Search, X, Loader2 } from "lucide-react";
|
|
import { api, type Suggestions } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
|
|
|
export function SearchBar({
|
|
busy,
|
|
onSearch,
|
|
onAlbum,
|
|
onArtist,
|
|
}: {
|
|
busy?: boolean;
|
|
onSearch: (q: string) => void;
|
|
onAlbum: (id: number) => void;
|
|
onArtist: (id: number) => void;
|
|
}) {
|
|
const [q, setQ] = React.useState("");
|
|
const [items, setItems] = React.useState<Item[]>([]);
|
|
const [open, setOpen] = React.useState(false);
|
|
const [sel, setSel] = React.useState(-1);
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
// Debounced so a fast typist makes one upstream call, not eight. The
|
|
// AbortController matters more than the delay: without it a slow response
|
|
// to "rad" can land after "radiohead" and repopulate the stale list.
|
|
React.useEffect(() => {
|
|
const v = q.trim();
|
|
if (v.length < 2) {
|
|
setItems([]);
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
const ac = new AbortController();
|
|
const t = setTimeout(async () => {
|
|
try {
|
|
const d: Suggestions = await api.suggest(v, ac.signal);
|
|
const next: Item[] = [
|
|
...d.tracks.map((t) => ({
|
|
kind: "track" as const,
|
|
label: t.title,
|
|
sub: t.artist,
|
|
cover: t.cover,
|
|
run: () => {
|
|
const nq = `${t.artist} ${t.title}`;
|
|
setQ(nq);
|
|
setOpen(false);
|
|
onSearch(nq);
|
|
},
|
|
})),
|
|
...d.albums.map((a) => ({
|
|
kind: "album" as const,
|
|
label: a.title,
|
|
sub: a.artist,
|
|
cover: a.cover,
|
|
meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "",
|
|
run: () => {
|
|
setOpen(false);
|
|
onAlbum(a.album_id);
|
|
},
|
|
})),
|
|
...d.artists.map((a) => ({
|
|
kind: "artist" as const,
|
|
label: a.name,
|
|
sub: a.nb_album ? `${a.nb_album} releases` : "Artist",
|
|
cover: a.cover,
|
|
run: () => {
|
|
setOpen(false);
|
|
onArtist(a.artist_id);
|
|
},
|
|
})),
|
|
];
|
|
setItems(next);
|
|
setSel(-1);
|
|
setOpen(next.length > 0);
|
|
} catch {
|
|
/* aborted or offline — leave the previous list alone */
|
|
}
|
|
}, 180);
|
|
return () => {
|
|
clearTimeout(t);
|
|
ac.abort();
|
|
};
|
|
}, [q, onSearch, onAlbum, onArtist]);
|
|
|
|
// "/" focuses search, the way every music app does.
|
|
React.useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const tag = (document.activeElement as HTMLElement)?.tagName;
|
|
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
const key = (e: React.KeyboardEvent) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setSel((s) => (items.length ? (s + 1) % items.length : -1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1));
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
if (sel >= 0 && items[sel]) items[sel].run();
|
|
else {
|
|
setOpen(false);
|
|
onSearch(q.trim());
|
|
}
|
|
}
|
|
};
|
|
|
|
const groups: { title: string; kind: Item["kind"] }[] = [
|
|
{ title: "Songs", kind: "track" },
|
|
{ title: "Albums", kind: "album" },
|
|
{ title: "Artists", kind: "artist" },
|
|
];
|
|
|
|
return (
|
|
<div className="relative z-50">
|
|
<div
|
|
className={cn(
|
|
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
|
"transition-all duration-250 ease-glass",
|
|
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
|
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
|
)}
|
|
>
|
|
<Search className="w-[18px] h-[18px] shrink-0 text-dim" />
|
|
<input
|
|
ref={inputRef}
|
|
type="search"
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
onKeyDown={key}
|
|
onFocus={() => items.length && setOpen(true)}
|
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
|
placeholder="Search songs, albums, artists…"
|
|
aria-label="Search"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base
|
|
font-medium tracking-[-0.1px] text-ink placeholder:text-dim
|
|
placeholder:font-normal"
|
|
/>
|
|
{busy && <Loader2 className="w-4 h-4 shrink-0 text-accent animate-spin" />}
|
|
{q && !busy && (
|
|
<button
|
|
onClick={() => {
|
|
setQ("");
|
|
inputRef.current?.focus();
|
|
}}
|
|
aria-label="Clear search"
|
|
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
|
glass-hi text-dim hover:text-ink cursor-pointer"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{open && items.length > 0 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -8, scale: 0.985 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
exit={{ opacity: 0, y: -8, scale: 0.985 }}
|
|
transition={{ duration: 0.18, ease: [0.22, 0.9, 0.28, 1] }}
|
|
className="sheet absolute top-[calc(100%+8px)] left-0 right-0 z-60 p-1.5
|
|
rounded-[20px] max-h-[min(64vh,460px)] overflow-y-auto no-scrollbar"
|
|
>
|
|
{groups.map((g) => {
|
|
const rows = items.filter((i) => i.kind === g.kind);
|
|
if (!rows.length) return null;
|
|
return (
|
|
<div key={g.kind}>
|
|
<div className="px-2.5 pt-2 pb-1 text-[10.5px] font-bold uppercase tracking-[0.9px] text-dim">
|
|
{g.title}
|
|
</div>
|
|
{rows.map((it) => {
|
|
const idx = items.indexOf(it);
|
|
return (
|
|
<div
|
|
key={`${g.kind}-${idx}`}
|
|
role="option"
|
|
aria-selected={idx === sel}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
it.run();
|
|
}}
|
|
onMouseEnter={() => setSel(idx)}
|
|
className={cn(
|
|
"flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors",
|
|
idx === sel && "bg-accent-soft",
|
|
)}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={it.cover ?? ""}
|
|
alt=""
|
|
loading="lazy"
|
|
className={cn(
|
|
"w-[38px] h-[38px] shrink-0 object-cover bg-faint",
|
|
it.kind === "artist" ? "rounded-full" : "rounded-lg",
|
|
)}
|
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium clamp1">{it.label}</div>
|
|
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
|
</div>
|
|
{it.meta && <div className="shrink-0 text-[11px] text-dim">{it.meta}</div>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|