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:
Aryan SinghandClaude Opus 5 committed 2026-08-04 14:26:31 -05:00
1 parent bc46fd80b3
commit 3b4b68daaf
34 files changed
+10655 -281

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+174
View File
@@ -0,0 +1,174 @@
@import "tailwindcss";
/* ───────────────────────────────────────────────────────────────
muse design system
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
from whatever album is on screen; every tinted surface derives from them,
so the page takes on the palette of the music instead of a fixed brand
colour. JS writes those three numbers and nothing else.
─────────────────────────────────────────────────────────────── */
@theme inline {
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
--color-ink: var(--fg);
--color-dim: var(--dim);
--color-edge: var(--edge);
--color-edge-hi: var(--edge-hi);
--color-faint: var(--faint);
--color-ok: #3ddc84;
--color-err: #ff6b6b;
--color-warn: #ffc95c;
--radius-glass: 14px;
--radius-panel: 28px;
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
--font-sans:
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
"Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
}
:root {
--a-r: 120;
--a-g: 140;
--a-b: 255;
--bg: #08090d;
--fg: #f2f4f9;
--dim: #9aa3b6;
--faint: rgb(255 255 255 / 0.07);
--glass: rgb(255 255 255 / 0.055);
--glass-hi: rgb(255 255 255 / 0.085);
--edge: rgb(255 255 255 / 0.1);
--edge-hi: rgb(255 255 255 / 0.17);
--sheet: rgb(16 18 26 / 0.86);
--header: rgb(8 9 13 / 0.62);
--scrim: rgb(8 9 13 / 0.74);
--scrim-2: rgb(8 9 13 / 0.4);
--blur: saturate(170%) blur(22px);
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8;
--fg: #0e1116;
--dim: #5a6274;
--faint: rgb(10 14 25 / 0.07);
--glass: rgb(255 255 255 / 0.7);
--glass-hi: rgb(255 255 255 / 0.88);
--edge: rgb(10 14 25 / 0.09);
--edge-hi: rgb(10 14 25 / 0.16);
--sheet: rgb(255 255 255 / 0.95);
--header: rgb(244 245 248 / 0.72);
--scrim: rgb(255 255 255 / 0.78);
--scrim-2: rgb(255 255 255 / 0.5);
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
}
}
@layer base {
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overflow-x: hidden;
}
::selection {
background: var(--color-accent-soft);
}
}
@utility glass {
background: var(--glass);
border: 1px solid var(--edge);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
@utility glass-hi {
background: var(--glass-hi);
border: 1px solid var(--edge-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
}
@utility sheet {
background: var(--sheet);
border: 1px solid var(--edge-hi);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--shadow);
}
@utility clamp1 {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Very light noise over the gradients — large flat blurs band visibly on
8-bit panels without it. */
@utility grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
}
@utility no-scrollbar {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
input[type="search"]::-webkit-search-cancel-button {
display: none;
}
/* Keyframes live here rather than in a <style> tag inside a component: the
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
elements are blocked outright. Setting element.style from JS is fine —
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
@keyframes shimmer {
to {
background-position: -220% 0;
}
}
.shimmer {
background-image: linear-gradient(
100deg,
var(--glass) 30%,
var(--glass-hi) 50%,
var(--glass) 70%
);
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "muse",
description: "Request a song, get it in your library, correctly tagged.",
};
export const viewport: Viewport = {
themeColor: "#08090d",
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
/**
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
* but it makes the Docker build reach out to Google at build time, and
* -apple-system resolves to SF Pro on the devices this is actually used from,
* which is the look being aimed at anyway.
*/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="h-full antialiased">
<body className="flex min-h-full flex-col">{children}</body>
</html>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
import { api, type Me, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { cn } from "@/lib/utils";
import { Aurora } from "@/components/aurora";
import { SearchBar } from "@/components/search-bar";
import { TrackRow } from "@/components/track-row";
import { AlbumView } from "@/components/album-view";
import { ArtistView } from "@/components/artist-view";
import { BulkImport } from "@/components/bulk-import";
import { Requests, type JobsSnapshot } from "@/components/requests";
import { Skeletons } from "@/components/skeletons";
import { PlayerProvider } from "@/components/player";
import { ToastProvider } from "@/components/toast";
type Tab = "search" | "jobs" | "bulk";
type View =
| { k: "welcome" }
| { k: "results"; q: string }
| { k: "album"; id: number }
| { k: "artist"; id: number };
const EXAMPLES = [
"Radiohead Creep",
"Kendrick Lamar Money Trees",
"Fleetwood Mac Dreams",
"Tame Impala The Less I Know The Better",
"Frank Ocean Ivy",
];
export default function Page() {
return (
<ToastProvider>
<PlayerProvider>
<Muse />
</PlayerProvider>
</ToastProvider>
);
}
function Muse() {
const [tab, setTab] = React.useState<Tab>("search");
const [view, setView] = React.useState<View>({ k: "welcome" });
const [history, setHistory] = React.useState<View[]>([]);
const [results, setResults] = React.useState<Track[] | null>(null);
const [busy, setBusy] = React.useState(false);
const [me, setMe] = React.useState<Me | null>(null);
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
const go = React.useCallback((next: View) => {
setView((cur) => {
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
return next;
});
setTab("search");
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const back = () => {
setHistory((h) => {
const prev = h[h.length - 1];
if (prev) setView(prev);
return h.slice(0, -1);
});
};
const doSearch = React.useCallback(
async (q: string) => {
if (!q.trim()) return;
go({ k: "results", q });
setResults(null);
setBusy(true);
try {
const r = await api.search(q);
setResults(r.results);
if (r.results[0]) void tint(r.results[0].cover);
} catch {
setResults([]);
}
setBusy(false);
},
[go],
);
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
const refresh = React.useCallback(async () => {
try {
setSnap(await api.jobs());
} catch {
/* a 401 here just means the poll outlived the session */
}
}, []);
React.useEffect(() => {
api.me().then(setMe).catch(() => undefined);
void refresh();
const t = setInterval(refresh, 4000);
return () => clearInterval(t);
}, [refresh]);
const active =
(snap?.counts.queued ?? 0) +
(snap?.counts.searching ?? 0) +
(snap?.counts.downloading ?? 0);
return (
<>
<Aurora />
<header
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
backdrop-blur-[22px] backdrop-saturate-[1.7]"
>
<div className="mx-auto w-full max-w-[940px] px-4">
<div className="mb-3.5 flex items-center gap-3">
<motion.div
whileHover={{ rotate: 12, scale: 1.06 }}
transition={{ type: "spring", stiffness: 400, damping: 15 }}
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
border border-accent-line bg-accent-soft text-accent"
>
<Music2 className="h-5 w-5" />
</motion.div>
<div className="min-w-0 flex-1">
<h1 className="text-[19px] font-bold leading-tight tracking-[-0.4px]">muse</h1>
<p className="text-xs text-dim">search · request · it appears in Navidrome</p>
</div>
{me?.user && (
<div className="flex items-center gap-2 text-[12.5px] text-dim">
{me.avatar && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={me.avatar}
alt=""
className="h-6 w-6 rounded-full border border-edge"
/>
)}
<span className="hidden font-medium text-ink sm:inline">
{me.display || me.user}
</span>
{me.role && me.role !== "user" && (
<span
className="rounded-full border border-accent-line bg-accent-soft px-1.5
py-0.5 text-[10.5px] uppercase tracking-[0.6px] text-accent"
>
{me.role}
</span>
)}
<a
href="/auth/logout"
className="border-b border-transparent hover:border-edge-hi hover:text-ink"
>
Log out
</a>
</div>
)}
</div>
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
<nav className="flex pt-3">
{(
[
["search", "Search"],
["jobs", "Requests"],
["bulk", "Import list"],
] as [Tab, string][]
).map(([k, label]) => (
<button
key={k}
onClick={() => setTab(k)}
className={cn(
"relative mr-4 cursor-pointer px-1 pb-3 pt-2 text-[13.5px] font-medium transition-colors",
tab === k ? "text-ink" : "text-dim hover:text-ink",
)}
>
{label}
{k === "jobs" && active > 0 && (
<span
className="ml-1.5 inline-block min-w-[18px] rounded-full border
border-accent-line bg-accent-soft px-1.5 text-[11px]
font-bold text-accent"
>
{active}
</span>
)}
{tab === k && (
<motion.i
layoutId="tab-underline"
className="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-accent"
transition={{ type: "spring", stiffness: 420, damping: 34 }}
/>
)}
</button>
))}
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
<AnimatePresence mode="wait">
{tab === "search" && (
<motion.div
key="search"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
{history.length > 0 && (
<motion.button
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
onClick={back}
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
>
<ArrowLeft className="h-3.5 w-3.5" /> Back
</motion.button>
)}
{view.k === "welcome" && <Welcome onPick={doSearch} />}
{view.k === "results" &&
(results === null ? (
<Skeletons />
) : results.length === 0 ? (
<div className="py-12 text-center text-dim">
No matches. Try adding the artist name.
</div>
) : (
<motion.div layout className="flex flex-col gap-[7px]">
{results.map((t, i) => (
<TrackRow
key={t.deezer_id}
track={t}
index={i}
onAlbum={openAlbum}
onArtist={openArtist}
onQueued={refresh}
/>
))}
</motion.div>
))}
{view.k === "album" && (
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
)}
{view.k === "artist" && (
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
)}
</motion.div>
)}
{tab === "jobs" && (
<motion.div
key="jobs"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
<Requests snap={snap} refresh={refresh} />
</motion.div>
)}
{tab === "bulk" && (
<motion.div
key="bulk"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.22 }}
>
<BulkImport onQueued={refresh} />
</motion.div>
)}
</AnimatePresence>
</main>
</>
);
}
function Welcome({ onPick }: { onPick: (q: string) => void }) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 28 }}
className="px-5 py-14 text-center"
>
<motion.div
animate={{ y: [0, -7, 0] }}
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
border border-accent-line bg-accent-soft text-accent"
>
<Disc3 className="h-7 w-7" />
</motion.div>
<h2 className="mb-2 text-[23px] font-bold tracking-[-0.4px]">Find anything</h2>
<p className="mx-auto max-w-[46ch] text-sm text-dim">
Search a song and press{" "}
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-[11.5px] text-ink">
Enter
</kbd>
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
your library, tagged.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{EXAMPLES.map((e, i) => (
<motion.button
key={e}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + i * 0.05 }}
whileHover={{ y: -2 }}
onClick={() => onPick(e)}
className="glass cursor-pointer rounded-full px-3.5 py-1.5 text-[13px] text-dim
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
>
{e}
</motion.button>
))}
</div>
</motion.div>
);
}