Files
muse/web/components/bulk-import.tsx
T
Aryan SinghandClaude Opus 5 3b4b68daaf 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>
2026-08-04 14:26:31 -05:00

127 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}