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>
127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { ArrowRight } from "lucide-react";
|
||
import { api } from "@/lib/api";
|
||
import { useToast } from "@/components/toast";
|
||
import { Button } from "@/components/ui/button";
|
||
|
||
type Result = Awaited<ReturnType<typeof api.importList>>;
|
||
|
||
const PLACEHOLDER = `Radiohead - Creep
|
||
Juice WRLD – Lucid Dreams
|
||
3. Gorillaz - Feel Good Inc
|
||
Black Hole Sun by Soundgarden
|
||
https://deezer.com/album/14880711`;
|
||
|
||
export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||
const toast = useToast();
|
||
const [text, setText] = React.useState("");
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [out, setOut] = React.useState<Result | null>(null);
|
||
|
||
const run = async () => {
|
||
const t = text.trim();
|
||
if (!t) {
|
||
toast("Paste some songs first", true);
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setOut(null);
|
||
try {
|
||
const d = await api.importList(t);
|
||
setOut(d);
|
||
if (d.queued.length) {
|
||
setText("");
|
||
toast(`Queued ${d.queued.length} tracks`);
|
||
onQueued?.();
|
||
}
|
||
} catch (e) {
|
||
toast(`Import failed: ${(e as Error).message}`, true);
|
||
}
|
||
setBusy(false);
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
className="glass rounded-panel p-5"
|
||
>
|
||
<h2 className="mb-1 text-[17px] font-bold tracking-[-0.3px]">Import a list</h2>
|
||
<p className="mb-3.5 max-w-[62ch] text-[13px] text-dim">
|
||
One song per line. Deezer album and playlist URLs are expanded into their tracks. The
|
||
top match is taken automatically — every job shows what it resolved to, so a bad pick is
|
||
visible rather than silent.
|
||
</p>
|
||
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
spellCheck={false}
|
||
placeholder={PLACEHOLDER}
|
||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||
text-ink outline-none transition
|
||
placeholder:text-dim/65
|
||
focus:border-accent-line focus:shadow-[0_0_0_4px_var(--color-accent-soft)]"
|
||
/>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||
<Button onClick={run} disabled={busy}>
|
||
{busy ? "Queueing…" : "Queue all"}
|
||
</Button>
|
||
<span className="text-[12.5px] text-dim">
|
||
Top match taken automatically — check what each line resolved to.
|
||
</span>
|
||
</div>
|
||
|
||
<AnimatePresence>
|
||
{out && (
|
||
<motion.div
|
||
initial={{ opacity: 0, height: 0 }}
|
||
animate={{ opacity: 1, height: "auto" }}
|
||
exit={{ opacity: 0, height: 0 }}
|
||
className="mt-3.5 overflow-hidden"
|
||
>
|
||
{out.queued.length > 0 && (
|
||
<>
|
||
<div className="mb-1 text-[12.5px] text-dim">Queued {out.queued.length}:</div>
|
||
{out.queued.map((q, i) => (
|
||
<motion.div
|
||
key={q.id}
|
||
initial={{ opacity: 0, x: -8 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: Math.min(i * 0.03, 0.5) }}
|
||
className="flex items-center gap-2.5 border-b border-edge py-1.5 text-[13px] last:border-0"
|
||
>
|
||
{q.cover && (
|
||
/* eslint-disable-next-line @next/next/no-img-element */
|
||
<img
|
||
src={q.cover}
|
||
alt=""
|
||
loading="lazy"
|
||
className="h-[30px] w-[30px] shrink-0 rounded-md bg-faint object-cover"
|
||
/>
|
||
)}
|
||
<span className="shrink-0 text-dim">{q.line}</span>
|
||
<ArrowRight className="h-3 w-3 shrink-0 text-dim opacity-50" />
|
||
<span className="clamp1">{q.matched}</span>
|
||
</motion.div>
|
||
))}
|
||
</>
|
||
)}
|
||
{out.skipped.length > 0 && (
|
||
<div className="mt-2.5 text-[12.5px] leading-[1.7] text-err">
|
||
Skipped {out.skipped.length}:{" "}
|
||
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
|
||
</div>
|
||
)}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</motion.div>
|
||
);
|
||
}
|