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 { Square } from "lucide-react";
|
||||
import { mmss, type Track } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 30-second Deezer preview.
|
||||
*
|
||||
* Deliberately NOT a library player. Playing your own music belongs in
|
||||
* Navidrome, which already does offline, CarPlay, gapless and scrobbling, and
|
||||
* has a dozen native clients. This exists for one job: confirm you picked the
|
||||
* right recording before a Soulseek search is committed to it — which is the
|
||||
* cheapest possible guard against importing the wrong take.
|
||||
*/
|
||||
|
||||
type Ctx = {
|
||||
playing: number | null;
|
||||
toggle: (t: Track) => void;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const PlayerCtx = React.createContext<Ctx>({
|
||||
playing: null,
|
||||
toggle: () => {},
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
export const usePlayer = () => React.useContext(PlayerCtx);
|
||||
|
||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
||||
const [track, setTrack] = React.useState<Track | null>(null);
|
||||
const [pct, setPct] = React.useState(0);
|
||||
const [left, setLeft] = React.useState(30_000);
|
||||
|
||||
if (typeof window !== "undefined" && !audioRef.current) {
|
||||
audioRef.current = new Audio();
|
||||
audioRef.current.preload = "none";
|
||||
}
|
||||
|
||||
const stop = React.useCallback(() => {
|
||||
const a = audioRef.current;
|
||||
if (a) {
|
||||
a.pause();
|
||||
a.removeAttribute("src");
|
||||
a.load();
|
||||
}
|
||||
setTrack(null);
|
||||
setPct(0);
|
||||
}, []);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
(t: Track) => {
|
||||
const a = audioRef.current;
|
||||
if (!a || !t.preview) return;
|
||||
if (track?.deezer_id === t.deezer_id) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
a.pause();
|
||||
a.src = t.preview;
|
||||
void a.play().catch(() => undefined);
|
||||
setTrack(t);
|
||||
setPct(0);
|
||||
},
|
||||
[track, stop],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const a = audioRef.current;
|
||||
if (!a) return;
|
||||
const onTime = () => {
|
||||
const d = a.duration || 30;
|
||||
setPct((a.currentTime / d) * 100);
|
||||
setLeft((d - a.currentTime) * 1000);
|
||||
};
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
a.addEventListener("ended", stop);
|
||||
return () => {
|
||||
a.removeEventListener("timeupdate", onTime);
|
||||
a.removeEventListener("ended", stop);
|
||||
};
|
||||
}, [stop]);
|
||||
|
||||
// Stop playback when the tab is hidden — nobody wants a phantom 30s clip.
|
||||
React.useEffect(() => {
|
||||
const onHide = () => document.hidden && stop();
|
||||
document.addEventListener("visibilitychange", onHide);
|
||||
return () => document.removeEventListener("visibilitychange", onHide);
|
||||
}, [stop]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ playing: track?.deezer_id ?? null, toggle, stop }),
|
||||
[track, toggle, stop],
|
||||
);
|
||||
|
||||
return (
|
||||
<PlayerCtx.Provider value={value}>
|
||||
{children}
|
||||
<AnimatePresence>
|
||||
{track && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 22, x: "-50%" }}
|
||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||
exit={{ opacity: 0, y: 22, x: "-50%" }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 32 }}
|
||||
className="sheet fixed bottom-4 left-1/2 z-70 flex items-center gap-3
|
||||
rounded-[20px] py-2 pl-2 pr-4 w-[min(560px,calc(100vw-28px))]"
|
||||
>
|
||||
{track.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={track.cover}
|
||||
alt=""
|
||||
className="w-[42px] h-[42px] rounded-[9px] object-cover bg-faint shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[13.5px] font-semibold clamp1">{track.title}</div>
|
||||
<div className="text-xs text-dim clamp1">{track.artist}</div>
|
||||
</div>
|
||||
<div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0">
|
||||
<motion.i
|
||||
className="block h-full rounded-full bg-accent"
|
||||
animate={{ width: `${pct}%` }}
|
||||
transition={{ ease: "linear", duration: 0.25 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden sm:block text-[11.5px] text-dim tabular-nums shrink-0">
|
||||
{mmss(left)}
|
||||
</span>
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Stop preview"
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||
text-ink cursor-pointer transition hover:border-accent-line
|
||||
hover:text-accent"
|
||||
>
|
||||
<Square className="w-3 h-3 fill-current" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</PlayerCtx.Provider>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user