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

+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
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>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { Hero } from "@/components/hero";
import { TrackRow } from "@/components/track-row";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/toast";
import { Skeletons } from "@/components/skeletons";
export function AlbumView({
id,
onArtist,
onQueued,
}: {
id: number;
onArtist: (id: number) => void;
onQueued?: () => void;
}) {
const toast = useToast();
const [data, setData] = React.useState<{ album: AlbumHeader; tracks: Track[] } | null>(null);
const [err, setErr] = React.useState<string | null>(null);
const [batch, setBatch] = React.useState<"idle" | "busy" | "done">("idle");
const [queued, setQueued] = React.useState(0);
React.useEffect(() => {
let live = true;
setData(null);
setErr(null);
setBatch("idle");
api
.album(id)
.then((d) => {
if (!live) return;
setData(d);
void tint(d.album.cover_big ?? d.album.cover);
})
.catch((e) => live && setErr((e as Error).message));
return () => {
live = false;
};
}, [id]);
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
if (!data) return <Skeletons n={8} />;
const { album, tracks } = data;
const getAll = async () => {
setBatch("busy");
try {
const d = await api.requestBatch(tracks);
setQueued(d.queued);
setBatch("done");
toast(`Queued ${d.queued} tracks from ${album.title}`);
onQueued?.();
} catch (e) {
setBatch("idle");
toast(`Could not queue album: ${(e as Error).message}`, true);
}
};
return (
<>
<Hero
kind={album.record_type ?? "Album"}
title={album.title}
art={album.cover_big ?? album.cover}
by={
album.artist_id ? (
<button
onClick={() => onArtist(album.artist_id!)}
className="border-b border-edge-hi hover:text-accent hover:border-accent-line cursor-pointer"
>
{album.artist}
</button>
) : (
album.artist
)
}
facts={[
album.year,
album.genres?.[0],
album.label,
album.nb_tracks ? `${album.nb_tracks} tracks` : null,
album.duration ? mmss(album.duration * 1000) : null,
]}
actions={
<Button onClick={getAll} disabled={batch !== "idle"}>
{batch === "idle"
? `Get all ${tracks.length} tracks`
: batch === "busy"
? "Queueing…"
: `Queued ${queued}`}
</Button>
}
/>
<motion.div layout className="flex flex-col gap-[7px]">
{tracks.map((t, i) => (
<TrackRow
key={t.deezer_id}
track={t}
index={i}
showNumber
showArtist={false}
showAlbum={false}
onQueued={onQueued}
/>
))}
</motion.div>
</>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
import { tint } from "@/lib/colour";
import { Hero } from "@/components/hero";
import { TrackRow } from "@/components/track-row";
import { Skeletons } from "@/components/skeletons";
export function ArtistView({
id,
onAlbum,
onQueued,
}: {
id: number;
onAlbum: (id: number) => void;
onQueued?: () => void;
}) {
const [data, setData] = React.useState<{
artist: ArtistHeader;
tracks: Track[];
albums: AlbumCard[];
} | null>(null);
const [err, setErr] = React.useState<string | null>(null);
React.useEffect(() => {
let live = true;
setData(null);
setErr(null);
api
.artist(id)
.then((d) => {
if (!live) return;
setData(d);
void tint(d.artist.picture);
})
.catch((e) => live && setErr((e as Error).message));
return () => {
live = false;
};
}, [id]);
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
if (!data) return <Skeletons n={8} />;
const { artist, tracks, albums } = data;
return (
<>
<Hero
kind="Artist"
title={artist.name}
art={artist.picture}
round
facts={[
artist.nb_fan ? `${artist.nb_fan.toLocaleString()} fans` : null,
artist.nb_album ? `${artist.nb_album} releases` : null,
]}
/>
<SectionHead>Top tracks</SectionHead>
<motion.div layout className="flex flex-col gap-[7px]">
{tracks.map((t, i) => (
<TrackRow
key={t.deezer_id}
track={t}
index={i}
showArtist={false}
onAlbum={onAlbum}
onQueued={onQueued}
/>
))}
</motion.div>
{albums.length > 0 && (
<>
<SectionHead>Releases</SectionHead>
<div className="grid grid-cols-[repeat(auto-fill,minmax(136px,1fr))] gap-3.5">
{albums.map((a, i) => (
<motion.button
key={a.album_id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.025, 0.5) }}
whileHover={{ y: -4 }}
onClick={() => onAlbum(a.album_id)}
className="cursor-pointer text-left"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={a.cover ?? ""}
alt=""
loading="lazy"
className="mb-2 aspect-square w-full rounded-[13px] bg-faint object-cover
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
<div className="text-[13.5px] font-medium clamp1">{a.title}</div>
<div className="text-xs text-dim clamp1">
{[a.year, a.record_type].filter(Boolean).join(" · ")}
</div>
</motion.button>
))}
</div>
</>
)}
</>
);
}
function SectionHead({ children }: { children: React.ReactNode }) {
return (
<h3 className="mt-6 mb-3 text-xs font-bold uppercase tracking-[0.9px] text-dim">
{children}
</h3>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { motion } from "framer-motion";
/**
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
* the artwork sets, so the page takes on the palette of whatever is on screen.
*
* Kept faint on purpose: at full strength the gradient competes with the album
* art it is derived from, and the glass rows lose contrast against it.
*/
export function Aurora() {
return (
<>
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
<motion.i
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.i
className="absolute block rounded-full opacity-30 blur-[110px]
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
/>
{/* Channel-rotated so the palette has range rather than one flat wash,
but faint — a full-strength complement fights the artwork. */}
<motion.i
className="absolute block rounded-full opacity-[0.14] blur-[110px]
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
</>
);
}
+126
View File
@@ -0,0 +1,126 @@
"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>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
/**
* Album/artist header. The artwork itself, blown up and blurred, is the
* panel's own backdrop — cheaper and more faithful than trying to synthesise
* a gradient from the extracted accent, and it reads as the record rather
* than as a themed box.
*/
export function Hero({
kind,
title,
art,
round,
by,
facts,
actions,
}: {
kind: string;
title: string;
art: string | null;
round?: boolean;
by?: React.ReactNode;
facts?: (string | null | undefined)[];
actions?: React.ReactNode;
}) {
return (
<motion.div
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
>
{art && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={art}
alt=""
aria-hidden
className="absolute inset-0 z-0 h-full w-full scale-[1.35] object-cover
opacity-40 blur-[46px] saturate-[1.8]"
/>
)}
<div className="absolute inset-0 z-[1] bg-gradient-to-r from-[var(--scrim)] to-[var(--scrim-2)]" />
{art && (
<motion.img
layoutId={`art-${title}`}
src={art}
alt=""
className={cn(
"relative z-[2] w-[132px] h-[132px] sm:w-[150px] sm:h-[150px] shrink-0 object-cover",
"bg-faint shadow-[0_14px_40px_rgb(0_0_0/0.5)]",
round ? "rounded-full" : "rounded-2xl",
)}
/>
)}
<div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center">
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-[1px] text-accent">
{kind}
</div>
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
{title}
</h2>
{by && <div className="mb-2.5 text-[15px] font-semibold">{by}</div>}
{facts && (
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
{facts.filter(Boolean).map((f, i) => (
<span
key={i}
className="glass-hi rounded-full px-2.5 py-0.5 text-[11.5px] text-dim"
>
{f}
</span>
))}
</div>
)}
{actions && (
<div className="flex flex-wrap justify-center sm:justify-start gap-2.5">{actions}</div>
)}
</div>
</motion.div>
);
}
+148
View File
@@ -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>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { api, type Job } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type JobsSnapshot = {
jobs: Job[];
counts: Record<string, number>;
pending: number;
};
const PILL: Record<Job["state"], string> = {
imported: "text-ok border-ok/40 bg-ok/10",
failed: "text-err border-err/40 bg-err/10",
downloading: "text-warn border-warn/40 bg-warn/10",
searching: "text-warn border-warn/40 bg-warn/10",
queued: "text-warn border-warn/40 bg-warn/10",
};
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
const toast = useToast();
if (!snap) return null;
const { jobs, counts, pending } = snap;
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
const done = counts.imported ?? 0;
const failed = counts.failed ?? 0;
const total = active + done;
const stats: [string, number, string][] = [
["In flight", active, "text-accent"],
["Imported", done, "text-ok"],
["Failed", failed, "text-err"],
["Waiting", pending, ""],
];
return (
<>
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
{stats.map(([label, n, colour]) => (
<motion.div key={label} layout className="glass rounded-glass p-3.5">
<motion.b
key={n}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={cn("block text-2xl font-bold leading-tight tracking-[-0.7px]", colour)}
>
{n}
</motion.b>
<span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span>
</motion.div>
))}
</div>
<AnimatePresence>
{active > 0 && total > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 4 }}
exit={{ opacity: 0, height: 0 }}
className="mb-4 overflow-hidden rounded-full bg-faint"
>
<motion.i
className="block h-1 rounded-full bg-accent"
animate={{ width: `${(done / total) * 100}%` }}
transition={{ ease: "easeOut", duration: 0.5 }}
/>
</motion.div>
)}
</AnimatePresence>
{jobs.length === 0 ? (
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
) : (
<motion.div layout className="flex flex-col gap-[7px]">
<AnimatePresence initial={false}>
{jobs.map((j) => (
<motion.div
key={j.id}
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
>
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
{j.cover && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={j.cover}
alt=""
loading="lazy"
className="h-full w-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-[14.5px] font-semibold clamp1">{j.title}</div>
<div className="mt-px text-[12.5px] text-dim clamp1">
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
</div>
</div>
{j.state === "failed" && (
<Button
variant="quiet"
size="sm"
onClick={async () => {
await api.retry(j.id);
toast("Requeued");
refresh();
}}
>
Retry
</Button>
)}
<span
className={cn(
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
"text-[11px] font-semibold capitalize",
PILL[j.state] ?? "text-dim border-edge",
)}
>
{(j.state === "searching" || j.state === "downloading") && (
<motion.span
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
animate={{ opacity: [1, 0.25, 1] }}
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
/>
)}
{j.state}
</span>
</motion.div>
))}
</AnimatePresence>
</motion.div>
)}
</>
);
}
+233
View File
@@ -0,0 +1,233 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Search, X, Loader2 } from "lucide-react";
import { api, type Suggestions } from "@/lib/api";
import { cn } from "@/lib/utils";
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
export function SearchBar({
busy,
onSearch,
onAlbum,
onArtist,
}: {
busy?: boolean;
onSearch: (q: string) => void;
onAlbum: (id: number) => void;
onArtist: (id: number) => void;
}) {
const [q, setQ] = React.useState("");
const [items, setItems] = React.useState<Item[]>([]);
const [open, setOpen] = React.useState(false);
const [sel, setSel] = React.useState(-1);
const inputRef = React.useRef<HTMLInputElement>(null);
// Debounced so a fast typist makes one upstream call, not eight. The
// AbortController matters more than the delay: without it a slow response
// to "rad" can land after "radiohead" and repopulate the stale list.
React.useEffect(() => {
const v = q.trim();
if (v.length < 2) {
setItems([]);
setOpen(false);
return;
}
const ac = new AbortController();
const t = setTimeout(async () => {
try {
const d: Suggestions = await api.suggest(v, ac.signal);
const next: Item[] = [
...d.tracks.map((t) => ({
kind: "track" as const,
label: t.title,
sub: t.artist,
cover: t.cover,
run: () => {
const nq = `${t.artist} ${t.title}`;
setQ(nq);
setOpen(false);
onSearch(nq);
},
})),
...d.albums.map((a) => ({
kind: "album" as const,
label: a.title,
sub: a.artist,
cover: a.cover,
meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "",
run: () => {
setOpen(false);
onAlbum(a.album_id);
},
})),
...d.artists.map((a) => ({
kind: "artist" as const,
label: a.name,
sub: a.nb_album ? `${a.nb_album} releases` : "Artist",
cover: a.cover,
run: () => {
setOpen(false);
onArtist(a.artist_id);
},
})),
];
setItems(next);
setSel(-1);
setOpen(next.length > 0);
} catch {
/* aborted or offline — leave the previous list alone */
}
}, 180);
return () => {
clearTimeout(t);
ac.abort();
};
}, [q, onSearch, onAlbum, onArtist]);
// "/" focuses search, the way every music app does.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (document.activeElement as HTMLElement)?.tagName;
if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
const key = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => (items.length ? (s + 1) % items.length : -1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1));
} else if (e.key === "Escape") {
setOpen(false);
} else if (e.key === "Enter") {
e.preventDefault();
if (sel >= 0 && items[sel]) items[sel].run();
else {
setOpen(false);
onSearch(q.trim());
}
}
};
const groups: { title: string; kind: Item["kind"] }[] = [
{ title: "Songs", kind: "track" },
{ title: "Albums", kind: "album" },
{ title: "Artists", kind: "artist" },
];
return (
<div className="relative z-50">
<div
className={cn(
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
"transition-all duration-250 ease-glass",
"focus-within:border-accent-line focus-within:bg-glass-hi",
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
)}
>
<Search className="w-[18px] h-[18px] shrink-0 text-dim" />
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={key}
onFocus={() => items.length && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 120)}
placeholder="Search songs, albums, artists…"
aria-label="Search"
autoComplete="off"
spellCheck={false}
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base
font-medium tracking-[-0.1px] text-ink placeholder:text-dim
placeholder:font-normal"
/>
{busy && <Loader2 className="w-4 h-4 shrink-0 text-accent animate-spin" />}
{q && !busy && (
<button
onClick={() => {
setQ("");
inputRef.current?.focus();
}}
aria-label="Clear search"
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
glass-hi text-dim hover:text-ink cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<AnimatePresence>
{open && items.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -8, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.985 }}
transition={{ duration: 0.18, ease: [0.22, 0.9, 0.28, 1] }}
className="sheet absolute top-[calc(100%+8px)] left-0 right-0 z-60 p-1.5
rounded-[20px] max-h-[min(64vh,460px)] overflow-y-auto no-scrollbar"
>
{groups.map((g) => {
const rows = items.filter((i) => i.kind === g.kind);
if (!rows.length) return null;
return (
<div key={g.kind}>
<div className="px-2.5 pt-2 pb-1 text-[10.5px] font-bold uppercase tracking-[0.9px] text-dim">
{g.title}
</div>
{rows.map((it) => {
const idx = items.indexOf(it);
return (
<div
key={`${g.kind}-${idx}`}
role="option"
aria-selected={idx === sel}
onMouseDown={(e) => {
e.preventDefault();
it.run();
}}
onMouseEnter={() => setSel(idx)}
className={cn(
"flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors",
idx === sel && "bg-accent-soft",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={it.cover ?? ""}
alt=""
loading="lazy"
className={cn(
"w-[38px] h-[38px] shrink-0 object-cover bg-faint",
it.kind === "artist" ? "rounded-full" : "rounded-lg",
)}
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium clamp1">{it.label}</div>
<div className="text-xs text-dim clamp1">{it.sub}</div>
</div>
{it.meta && <div className="shrink-0 text-[11px] text-dim">{it.meta}</div>}
</div>
);
})}
</div>
);
})}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { motion } from "framer-motion";
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
export function Skeletons({ n = 6 }: { n?: number }) {
return (
<div className="flex flex-col gap-[7px]">
{Array.from({ length: n }).map((_, i) => (
<motion.div
key={i}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.04 }}
className="shimmer h-[68px] rounded-glass border border-edge"
/>
))}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
type Toast = { id: number; msg: string; err?: boolean };
type Ctx = (msg: string, err?: boolean) => void;
const ToastCtx = React.createContext<Ctx>(() => {});
export const useToast = () => React.useContext(ToastCtx);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = React.useState<Toast[]>([]);
const push = React.useCallback<Ctx>((msg, err) => {
const id = Date.now() + Math.random();
setItems((x) => [...x, { id, msg, err }]);
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
}, []);
return (
<ToastCtx.Provider value={push}>
{children}
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
<AnimatePresence>
{items.map((t) => (
<motion.div
key={t.id}
layout
initial={{ opacity: 0, y: 18, scale: 0.94 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.96 }}
transition={{ type: "spring", stiffness: 420, damping: 30 }}
className={cn(
"sheet rounded-full px-5 py-2.5 text-[13.5px] font-medium text-center",
"max-w-[calc(100vw-32px)]",
t.err && "border-err/50 text-err",
)}
>
{t.msg}
</motion.div>
))}
</AnimatePresence>
</div>
</ToastCtx.Provider>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Play, Pause } from "lucide-react";
import { api, mmss, type Track } from "@/lib/api";
import { usePlayer } from "@/components/player";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function TrackRow({
track,
index,
showNumber,
showArtist = true,
showAlbum = true,
onAlbum,
onArtist,
onQueued,
}: {
track: Track;
index: number;
showNumber?: boolean;
showArtist?: boolean;
showAlbum?: boolean;
onAlbum?: (id: number) => void;
onArtist?: (id: number) => void;
onQueued?: () => void;
}) {
const { playing, toggle } = usePlayer();
const toast = useToast();
const [state, setState] = React.useState<"idle" | "busy" | "done">("idle");
const isPlaying = playing === track.deezer_id;
const queue = async () => {
setState("busy");
try {
await api.request(track);
setState("done");
toast(`Queued ${track.artist} — ${track.title}`);
onQueued?.();
} catch (e) {
setState("idle");
toast(`Could not queue: ${(e as Error).message}`, true);
}
};
const facts: React.ReactNode[] = [];
if (showAlbum && track.album)
facts.push(
track.album_id && onAlbum ? (
<button
key="al"
onClick={() => onAlbum(track.album_id!)}
className="hover:text-accent hover:underline cursor-pointer"
>
{track.album}
</button>
) : (
<span key="al">{track.album}</span>
),
);
if (track.year) facts.push(<span key="yr">{track.year}</span>);
if (track.genre) facts.push(<span key="gn">{track.genre}</span>);
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
whileHover={{ y: -1 }}
className={cn(
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
isPlaying && "border-accent-line bg-accent-soft",
)}
>
{showNumber && (
<div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums">
{track.track ?? ""}
</div>
)}
<button
onClick={() => toggle(track)}
disabled={!track.preview}
aria-label={track.preview ? `Preview ${track.title}` : "No preview available"}
className={cn(
"relative w-[50px] h-[50px] shrink-0 rounded-[9px] overflow-hidden bg-faint group",
track.preview ? "cursor-pointer" : "cursor-default",
)}
>
{track.cover && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={track.cover}
alt=""
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
/>
)}
{track.preview && (
<div
className={cn(
"absolute inset-0 grid place-items-center bg-black/50 text-white",
"transition-opacity duration-200",
isPlaying ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
{isPlaying ? (
<Pause className="w-[18px] h-[18px] fill-current" />
) : (
<Play className="w-[18px] h-[18px] fill-current" />
)}
</div>
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1">
{track.title}
{track.explicit && (
<span className="ml-1.5 align-[1px] inline-block rounded-[3px] border border-edge bg-faint px-1 text-[9px] font-bold text-dim">
E
</span>
)}
</div>
<div className="mt-px text-[12.5px] text-dim clamp1">
{showArtist && track.artist_id && onArtist ? (
<button
onClick={() => onArtist(track.artist_id!)}
className="hover:text-accent hover:underline cursor-pointer"
>
{track.artist}
</button>
) : showArtist ? (
<span>{track.artist}</span>
) : null}
{facts.map((f, i) => (
<React.Fragment key={i}>
{(showArtist || i > 0) && <span className="mx-1.5 opacity-50">·</span>}
{f}
</React.Fragment>
))}
</div>
</div>
<div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums">
{track.length ? mmss(track.length) : ""}
</div>
<Button
variant="outline"
size="pill"
onClick={queue}
disabled={state !== "idle"}
className={cn(
"shrink-0",
state === "done" && "border-ok/50 text-ok bg-ok/10 hover:bg-ok/10 hover:text-ok hover:border-ok/50",
)}
>
{state === "idle" ? "Get" : state === "busy" ? "…" : "Queued"}
</Button>
</motion.div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"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";
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+169
View File
@@ -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")}`;
};
+85
View File
@@ -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]));
}
+7
View File
@@ -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));
}
+42
View File
@@ -0,0 +1,42 @@
import type { NextConfig } from "next";
/**
* Static export, served by the FastAPI app.
*
* The alternative — running `next start` alongside uvicorn — means a Node
* runtime in the image, a second process to supervise and health-check, and a
* second thing that can be down while the other is up. muse has no use for
* SSR: every view is behind auth and driven by live JSON, so there is nothing
* to render on a server that the client would not immediately re-render.
*
* So `next build` emits plain files into out/, FastAPI serves them, and the
* deployment stays what it was: one container, one process, one healthcheck.
* The auth middleware still guards the whole surface, because it is the same
* server handing out the HTML.
*/
const isProd = process.env.NODE_ENV === "production";
const API = process.env.MUSE_API_ORIGIN ?? "http://127.0.0.1:5041";
const nextConfig: NextConfig = {
...(isProd ? { output: "export" as const } : {}),
// No Next image optimiser without a server. Artwork already arrives from
// Deezer's CDN at the size it is displayed, so there is nothing to optimise.
images: { unoptimized: true },
// Dev only: `next dev` on :3000 proxies the API to the FastAPI process so
// the browser sees a single origin and cookies behave as they do in prod.
...(isProd
? {}
: {
async rewrites() {
return [
{ source: "/api/:path*", destination: `${API}/api/:path*` },
{ source: "/auth/:path*", destination: `${API}/auth/:path*` },
{ source: "/healthz", destination: `${API}/healthz` },
];
},
}),
};
export default nextConfig;
+7843
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.2.6",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-scroll-area": "^1.2.18",
"@radix-ui/react-slot": "^1.3.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.43.0",
"lucide-react": "^1.28.0",
"next": "16.3.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}