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>
52 lines
2.0 KiB
TypeScript
52 lines
2.0 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { Slot } from "@radix-ui/react-slot";
|
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const button = cva(
|
|
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
|
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
|
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
|
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
|
{
|
|
variants: {
|
|
variant: {
|
|
// Solid accent. The label is near-black because the accent is clamped
|
|
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
|
// to stay in step or this button becomes unreadable on some albums.
|
|
primary: "bg-accent text-[#08090d] hover:brightness-110 active:scale-[0.97]",
|
|
ghost:
|
|
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
|
outline:
|
|
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
|
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
|
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
|
},
|
|
size: {
|
|
sm: "text-xs px-3 py-1.5 rounded-full",
|
|
md: "text-sm px-4.5 py-2.5 rounded-xl",
|
|
pill: "text-[13px] px-4 py-2 rounded-full",
|
|
},
|
|
},
|
|
defaultVariants: { variant: "primary", size: "md" },
|
|
},
|
|
);
|
|
|
|
export interface ButtonProps
|
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
VariantProps<typeof button> {
|
|
asChild?: boolean;
|
|
}
|
|
|
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, size, asChild, ...props }, ref) => {
|
|
const Comp = asChild ? Slot : "button";
|
|
return (
|
|
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
|
);
|
|
},
|
|
);
|
|
Button.displayName = "Button";
|