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
+169
@@ -0,0 +1,169 @@
|
||||
/** Types mirror what app.py's track_row() and endpoints actually return. */
|
||||
|
||||
export type Track = {
|
||||
deezer_id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
artist_id: number | null;
|
||||
artist_pic: string | null;
|
||||
album: string;
|
||||
album_id: number | null;
|
||||
cover: string | null;
|
||||
cover_big: string | null;
|
||||
length: number;
|
||||
preview: string | null;
|
||||
explicit: boolean;
|
||||
isrc: string | null;
|
||||
rank: number;
|
||||
year: string | null;
|
||||
genre: string | null;
|
||||
track: number | null;
|
||||
disc?: number | null;
|
||||
};
|
||||
|
||||
export type AlbumHeader = {
|
||||
album_id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
artist_id: number | null;
|
||||
cover: string | null;
|
||||
cover_big: string | null;
|
||||
year: string | null;
|
||||
genres: string[];
|
||||
label: string | null;
|
||||
nb_tracks: number | null;
|
||||
record_type: string | null;
|
||||
duration: number | null;
|
||||
};
|
||||
|
||||
export type ArtistHeader = {
|
||||
artist_id: number;
|
||||
name: string;
|
||||
picture: string | null;
|
||||
nb_fan: number | null;
|
||||
nb_album: number | null;
|
||||
};
|
||||
|
||||
export type AlbumCard = {
|
||||
album_id: number;
|
||||
title: string;
|
||||
cover: string | null;
|
||||
cover_big: string | null;
|
||||
year: string | null;
|
||||
record_type: string | null;
|
||||
nb_tracks: number | null;
|
||||
};
|
||||
|
||||
export type Suggestions = {
|
||||
tracks: {
|
||||
deezer_id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
cover: string | null;
|
||||
preview: string | null;
|
||||
}[];
|
||||
albums: {
|
||||
album_id: number;
|
||||
title: string;
|
||||
artist: string;
|
||||
cover: string | null;
|
||||
nb_tracks: number | null;
|
||||
}[];
|
||||
artists: {
|
||||
artist_id: number;
|
||||
name: string;
|
||||
cover: string | null;
|
||||
nb_album: number | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
id: string;
|
||||
state: "queued" | "searching" | "downloading" | "imported" | "failed";
|
||||
detail: string | null;
|
||||
artist: string;
|
||||
title: string;
|
||||
album: string | null;
|
||||
year: string | null;
|
||||
cover: string | null;
|
||||
genre: string | null;
|
||||
duration: number | null;
|
||||
requested_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type Me = {
|
||||
user: string | null;
|
||||
auth: string;
|
||||
role?: string;
|
||||
display?: string | null;
|
||||
avatar?: string | null;
|
||||
quota?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every response goes through here so a 401 is a typed failure rather than a
|
||||
* JSON parse error thirty lines away. Sessions expire, and when one does the
|
||||
* UI should say "signed out", not "Unexpected token < in JSON".
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function get<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(path, { credentials: "same-origin", ...init });
|
||||
if (r.status === 401) throw new ApiError(401, "signed out");
|
||||
if (!r.ok) throw new ApiError(r.status, `HTTP ${r.status}`);
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
return get<T>(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => get<Me>("/api/me"),
|
||||
search: (q: string) =>
|
||||
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||
suggest: (q: string, signal?: AbortSignal) =>
|
||||
get<Suggestions>(`/api/suggest?q=${encodeURIComponent(q)}`, { signal }),
|
||||
album: (id: number | string) =>
|
||||
get<{ album: AlbumHeader; tracks: Track[] }>(
|
||||
`/api/album/${encodeURIComponent(String(id))}`,
|
||||
),
|
||||
artist: (id: number | string) =>
|
||||
get<{ artist: ArtistHeader; tracks: Track[]; albums: AlbumCard[] }>(
|
||||
`/api/artist/${encodeURIComponent(String(id))}`,
|
||||
),
|
||||
request: (t: Track) => post<{ id: string }>("/api/request", t),
|
||||
requestBatch: (tracks: Track[]) =>
|
||||
post<{ queued: number; ids: string[]; pending: number }>(
|
||||
"/api/request/batch",
|
||||
{ tracks },
|
||||
),
|
||||
jobs: () =>
|
||||
get<{ jobs: Job[]; counts: Record<string, number>; pending: number }>(
|
||||
"/api/jobs",
|
||||
),
|
||||
retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`),
|
||||
importList: (text: string) =>
|
||||
post<{
|
||||
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
||||
skipped: { line: string; why: string }[];
|
||||
pending: number;
|
||||
}>("/api/import", { text }),
|
||||
};
|
||||
|
||||
export const mmss = (ms: number | null | undefined) => {
|
||||
const t = Math.round((ms || 0) / 1000);
|
||||
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Dominant colour of an album cover, used as the page accent.
|
||||
*
|
||||
* Deezer's CDN sends `access-control-allow-origin: *`, so the artwork can be
|
||||
* drawn into a canvas without tainting it and read back. That is the whole
|
||||
* reason this works client-side with no image proxy and no server CPU — it
|
||||
* was checked before the feature was designed around it.
|
||||
*/
|
||||
|
||||
const cache = new Map<string, [number, number, number]>();
|
||||
|
||||
export function dominant(url: string | null): Promise<[number, number, number] | null> {
|
||||
if (!url) return Promise.resolve(null);
|
||||
const hit = cache.get(url);
|
||||
if (hit) return Promise.resolve(hit);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
try {
|
||||
const n = 32;
|
||||
const cv = document.createElement("canvas");
|
||||
cv.width = cv.height = n;
|
||||
const cx = cv.getContext("2d", { willReadFrequently: true });
|
||||
if (!cx) return resolve(null);
|
||||
cx.drawImage(img, 0, 0, n, n);
|
||||
const d = cx.getImageData(0, 0, n, n).data;
|
||||
|
||||
// Coarse 4-bit-per-channel histogram, weighted by saturation. Most
|
||||
// album art is dominated by greys and near-blacks in raw pixel count,
|
||||
// which say nothing about its palette — the weighting is what keeps
|
||||
// the accent off them.
|
||||
const bins = new Map<number, { w: number; r: number; g: number; b: number; n: number }>();
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
if (d[i + 3] < 128) continue;
|
||||
const r = d[i], g = d[i + 1], b = d[i + 2];
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b);
|
||||
if (mx < 32 || mx > 246) continue; // near-black / blown out
|
||||
const sat = mx ? (mx - mn) / mx : 0;
|
||||
const key = (r >> 4) * 256 + (g >> 4) * 16 + (b >> 4);
|
||||
const cur = bins.get(key) ?? { w: 0, r: 0, g: 0, b: 0, n: 0 };
|
||||
cur.w += 1 + sat * 5;
|
||||
cur.r += r; cur.g += g; cur.b += b; cur.n++;
|
||||
bins.set(key, cur);
|
||||
}
|
||||
|
||||
let best: { w: number; r: number; g: number; b: number; n: number } | null = null;
|
||||
for (const v of bins.values()) if (!best || v.w > best.w) best = v;
|
||||
if (!best) return resolve(null);
|
||||
|
||||
const [r0, g0, b0] = [best.r / best.n, best.g / best.n, best.b / best.n];
|
||||
|
||||
// Clamp luminance so the accent stays readable as text on the dark
|
||||
// shell AND dark enough to sit behind the near-black label on solid
|
||||
// accent buttons. Album art happily supplies both #0a0a0a and #fffef8.
|
||||
const lum = (0.2126 * r0 + 0.7152 * g0 + 0.0722 * b0) / 255;
|
||||
const k = lum < 0.45 ? 0.45 / Math.max(lum, 0.04) : lum > 0.82 ? 0.82 / lum : 1;
|
||||
const rgb = [r0, g0, b0].map((v) =>
|
||||
Math.max(0, Math.min(255, Math.round(v * k))),
|
||||
) as [number, number, number];
|
||||
|
||||
cache.set(url, rgb);
|
||||
resolve(rgb);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
let token = 0;
|
||||
|
||||
/** Apply an artwork's colour as the live accent. Last call wins. */
|
||||
export async function tint(url: string | null) {
|
||||
const mine = ++token;
|
||||
const rgb = await dominant(url);
|
||||
if (!rgb || mine !== token) return; // a newer view won the race
|
||||
const s = document.documentElement.style;
|
||||
s.setProperty("--a-r", String(rgb[0]));
|
||||
s.setProperty("--a-g", String(rgb[1]));
|
||||
s.setProperty("--a-b", String(rgb[2]));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** shadcn's `cn` — merge conditional classes, last Tailwind utility wins. */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in new issue
Block a user