Add review, discover, email; rebuild the design system

Three features and a typographic pass over everything.

REVIEW. Picking a file and trusting it are different questions, so they now
use different numbers. The existing rank decides which candidate to fetch and
is right to weigh peer upload slots and queue length -- but none of that says
anything about whether the file is the correct recording, and a fast peer must
not be able to raise the confidence of a wrong match. So confidence() scores
the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag
you did not ask for multiplies by 0.8 AND caps below the auto threshold.

Confidence gates the IMPORT, not the download. The transfer completes either
way and the file waits in downloads, so approving is instant and rejecting is
a delete. Holding the transfer instead would mean the peer is usually gone by
the time anyone looks.

The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75
-- title exactly right, artist simply absent, the commonest honest case there
is -- and must be reviewed rather than binned. And "Creep (Live at
Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what
stops a live recording auto-filing over the studio version. Ask for "Creep
(Acoustic)" and it auto-imports at 0.97, because then the variant is the
request.

DISCOVER. Related artists seeded from what you have actually imported, plus a
recap of top artists, genres and decades. Built on muse's own request history,
never on listening data: Navidrome owns play counts and history, and a second
source of truth for those would disagree with the one you listen through.

EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on
review-needed. smtplib is blocking so it runs off the event loop, and every
failure is logged and swallowed -- a notification is never worth failing an
import over. Google accounts need no configuration, since the identity is an
address. Each user owns their own setting under Settings, deliberately not an
admin route: an address you cannot change yourself is someone else's chore.

DESIGN. A real type scale (display/title/headline/body/callout/caption) whose
tracking tightens as size grows, which is the single biggest tell of typography
nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey
rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic
colours are now per-theme: the amber tuned for the dark background failed
contrast outright on white, which was exactly the text a reviewer most needs to
read. The tab underline became a sliding pill so the eye tracks one object, and
Review only appears when something is actually waiting.

Add tests/test_matching.py: 13 assertions on the confidence bands rather than
the exact scores. 90 tests pass across the three suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 02:10:33 -05:00
1 parent cc3490483e
commit 205e0a44b8
23 files changed
+1484 -102

No files matched your search

+4 -4
View File
@@ -35,12 +35,12 @@ export function ActivityChart({ data }: { data: Activity }) {
<div className="glass rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<div>
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Requests · last {data.days} days
</h3>
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
{shown ? shown.n : totals.n}
<span className="ml-2 text-[13px] font-normal text-dim">
<span className="ml-2 text-callout font-normal text-dim">
{shown
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
@@ -51,7 +51,7 @@ export function ActivityChart({ data }: { data: Activity }) {
</span>
</div>
</div>
<div className="flex gap-3 text-[11.5px] text-dim">
<div className="flex gap-3 text-caption text-dim">
<Legend className="bg-accent" label={`${shown ? shown.imported : totals.imported} imported`} />
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
</div>
@@ -99,7 +99,7 @@ export function ActivityChart({ data }: { data: Activity }) {
})}
</svg>
<div className="mt-1.5 flex justify-between text-[11px] text-dim">
<div className="mt-1.5 flex justify-between text-caption text-dim">
<span>{fmt(data.series[0]?.date)}</span>
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
</div>
+5 -5
View File
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-[13px]",
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
healthy ? "border-ok/30" : "border-err/40",
)}
>
@@ -142,7 +142,7 @@ function Breakdown({
const max = Math.max(1, ...rows.map((r) => r.n));
return (
<div className="glass rounded-panel p-5">
<h3 className="mb-3 text-[13px] font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
<div className="flex flex-col gap-2">
{rows.slice(0, 8).map((r, i) => (
<motion.div
@@ -152,7 +152,7 @@ function Breakdown({
transition={{ delay: i * 0.04 }}
className="flex items-center gap-3"
>
<span className="w-32 shrink-0 text-[13px] clamp1">{r.label}</span>
<span className="w-32 shrink-0 text-callout clamp1">{r.label}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint">
<motion.i
className="block h-full rounded-full bg-accent"
@@ -161,11 +161,11 @@ function Breakdown({
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
/>
</div>
<span className="w-10 shrink-0 text-right text-[12.5px] tabular-nums text-dim">
<span className="w-10 shrink-0 text-right text-callout tabular-nums text-dim">
{r.n}
</span>
{r.sub && (
<span className="hidden w-16 shrink-0 text-right text-[11.5px] text-dim sm:block">
<span className="hidden w-16 shrink-0 text-right text-caption text-dim sm:block">
{r.sub}
</span>
)}
+2 -2
View File
@@ -28,11 +28,11 @@ export function StatCard({
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
className="glass rounded-glass p-3.5"
>
<div className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</div>
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
{value}
</div>
{sub && <div className="mt-0.5 text-[11.5px] text-dim clamp1">{sub}</div>}
{sub && <div className="mt-0.5 text-caption text-dim clamp1">{sub}</div>}
{pct != null && (
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
<motion.i
+15 -15
View File
@@ -25,10 +25,10 @@ export function UsersTable({
<div className="glass rounded-panel p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim">
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Users
</h3>
<p className="mt-1 text-[12.5px] text-dim">
<p className="mt-1 text-callout text-dim">
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
with that provider and they&rsquo;re in.
</p>
@@ -117,12 +117,12 @@ function UserRow({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1">
<div className="flex items-center gap-1.5 text-body font-semibold clamp1">
{u.display || u.username}
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
{isMe && <span className="text-[11px] font-normal text-dim">(you)</span>}
{isMe && <span className="text-caption font-normal text-dim">(you)</span>}
</div>
<div className="text-[12px] text-dim clamp1">
<div className="text-caption text-dim clamp1">
{u.username} · {u.provider}
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
{u.disabled && " · disabled"}
@@ -135,7 +135,7 @@ function UserRow({
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
aria-label={`Role for ${u.username}`}
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
px-2.5 py-1 text-[12px] text-ink outline-none
px-2.5 py-1 text-caption text-ink outline-none
focus:border-accent-line"
>
{roles.map((r) => (
@@ -188,7 +188,7 @@ function UserRow({
)}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]">
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-caption">
<QuotaField
label="Requests / month"
value={u.quota}
@@ -206,7 +206,7 @@ function UserRow({
}
/>
{u.bootstrap && (
<span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS">
<span className="text-caption text-warn" title="Listed in MUSE_ALLOWED_USERS">
bootstrap account — disabling won&rsquo;t lock them out
</span>
)}
@@ -295,7 +295,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
};
const input =
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " +
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
"text-ink outline-none focus:border-accent-line";
return (
@@ -307,7 +307,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
>
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
<span className="text-[11.5px] text-dim">
<span className="text-caption text-dim">
{provider === "github" ? "GitHub username" : "Google email"}
</span>
<input
@@ -320,14 +320,14 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Provider</span>
<span className="text-caption text-dim">Provider</span>
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
<option value="github" className="bg-[#12141c]">GitHub</option>
<option value="google" className="bg-[#12141c]">Google</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] text-dim">Role</span>
<span className="text-caption text-dim">Role</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
{roles.map((r) => (
<option key={r} value={r} className="bg-[#12141c]">
@@ -337,17 +337,17 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
</select>
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Req/month</span>
<span className="text-caption text-dim">Req/month</span>
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
</label>
<label className="flex w-24 flex-col gap-1">
<span className="text-[11.5px] text-dim">Storage GB</span>
<span className="text-caption text-dim">Storage GB</span>
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
</label>
<Button onClick={submit} disabled={busy}>
{busy ? "Inviting…" : "Invite"}
</Button>
<span className="w-full text-[11.5px] text-dim">0 means unlimited.</span>
<span className="w-full text-caption text-dim">0 means unlimited.</span>
</div>
</motion.div>
);
+1 -1
View File
@@ -96,7 +96,7 @@ export function ArtistView({
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-callout font-medium clamp1">{a.title}</div>
<div className="text-xs text-dim clamp1">
{[a.year, a.record_type].filter(Boolean).join(" · ")}
</div>
+6 -6
View File
@@ -49,8 +49,8 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
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">
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
<p className="mb-3.5 max-w-[62ch] text-callout 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.
@@ -72,7 +72,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
<Button onClick={run} disabled={busy}>
{busy ? "Queueing…" : "Queue all"}
</Button>
<span className="text-[12.5px] text-dim">
<span className="text-callout text-dim">
Top match taken automatically — check what each line resolved to.
</span>
</div>
@@ -87,14 +87,14 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
>
{out.queued.length > 0 && (
<>
<div className="mb-1 text-[12.5px] text-dim">Queued {out.queued.length}:</div>
<div className="mb-1 text-callout 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"
className="flex items-center gap-2.5 border-b border-edge py-1.5 text-callout last:border-0"
>
{q.cover && (
/* eslint-disable-next-line @next/next/no-img-element */
@@ -113,7 +113,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
</>
)}
{out.skipped.length > 0 && (
<div className="mt-2.5 text-[12.5px] leading-[1.7] text-err">
<div className="mt-2.5 text-callout leading-[1.7] text-err">
Skipped {out.skipped.length}:{" "}
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
</div>
+210
View File
@@ -0,0 +1,210 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
import { Skeletons } from "@/components/skeletons";
/**
* Recommendations and a personal recap, both derived from muse's OWN request
* history plus Deezer's related-artists graph.
*
* Deliberately not built on listening data: Navidrome owns play counts,
* favourites and history, and a second source of truth for those would
* disagree with the one the user actually listens through.
*/
export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }) {
const [d, setD] = React.useState<DiscoverData | null>(null);
const [r, setR] = React.useState<Recap | null>(null);
React.useEffect(() => {
api.discover().then(setD).catch(() => setD({ seeds: [], artists: [] }));
api.recap().then(setR).catch(() => undefined);
}, []);
if (!d) return <Skeletons n={4} />;
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
if (empty)
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="px-5 py-16 text-center"
>
<div
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
border border-accent-line bg-accent-soft text-accent"
>
<Compass className="h-6 w-6" />
</div>
<h2 className="text-title">Nothing to go on yet</h2>
<p className="mx-auto mt-1.5 max-w-[46ch] text-callout text-dim">
Recommendations are built from what you&rsquo;ve actually added. Request a few
tracks and this fills in.
</p>
</motion.div>
);
return (
<div className="flex flex-col gap-5">
{r && r.tracks > 0 && <RecapPanel r={r} />}
{d.artists.length > 0 && (
<section>
<SectionHead
icon={<Sparkles className="h-3.5 w-3.5" />}
title="Because you added"
sub={d.seeds.slice(0, 3).join(" · ")}
/>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6">
{d.artists.map((a, i) => (
<motion.button
key={a.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(i * 0.03, 0.3) }}
onClick={() => onArtist(a.name)}
className="glass lift ring group cursor-pointer rounded-panel p-3 text-center"
>
{a.picture ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={a.picture}
alt=""
className="mx-auto mb-2.5 h-20 w-20 rounded-full border border-edge object-cover"
/>
) : (
<div className="mx-auto mb-2.5 grid h-20 w-20 place-items-center rounded-full bg-faint text-dim">
<User className="h-7 w-7" />
</div>
)}
<div className="text-callout font-semibold clamp1">{a.name}</div>
{a.fans ? (
<div className="text-caption text-dim nums">
{a.fans.toLocaleString()} fans
</div>
) : null}
</motion.button>
))}
</div>
</section>
)}
</div>
);
}
function RecapPanel({ r }: { r: Recap }) {
const maxDecade = Math.max(1, ...r.decades.map((d) => d.n));
return (
<section>
<SectionHead
icon={<CalendarDays className="h-3.5 w-3.5" />}
title="Your year in muse"
sub={`${r.tracks} tracks · ${bytes(r.bytes)}`}
/>
<div className="grid gap-2.5 md:grid-cols-3">
<Card title="Most requested">
{r.artists.slice(0, 5).map((a, i) => (
<Row key={a.name} i={i} label={a.name} n={a.n} />
))}
</Card>
<Card title="Genres">
{r.genres.slice(0, 5).map((g, i) => (
<Row key={g.name} i={i} label={g.name} n={g.n} />
))}
{r.genres.length === 0 && <Muted>No genre data yet.</Muted>}
</Card>
<Card title="Decades">
{r.decades.length === 0 && <Muted>No release years yet.</Muted>}
{r.decades.map((d, i) => (
<motion.div
key={d.decade}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className="flex items-center gap-2.5"
>
<span className="w-12 shrink-0 text-callout text-dim nums">{d.decade}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-pill bg-faint">
<motion.i
className="block h-full rounded-pill bg-accent"
initial={{ width: 0 }}
animate={{ width: `${(d.n / maxDecade) * 100}%` }}
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
/>
</div>
<span className="w-6 shrink-0 text-right text-caption text-dim nums">
{d.n}
</span>
</motion.div>
))}
{r.busiest_day && (
<div className="mt-1 border-t border-edge pt-2 text-caption text-dim">
Busiest day {r.busiest_day.date} — {r.busiest_day.n} tracks
</div>
)}
</Card>
</div>
</section>
);
}
function SectionHead({
icon,
title,
sub,
}: {
icon: React.ReactNode;
title: string;
sub?: string;
}) {
return (
<div className="mb-2.5 flex items-baseline gap-2">
<span className="translate-y-[1px] text-accent">{icon}</span>
<h2 className="text-headline">{title}</h2>
{sub && <span className="text-caption text-dim clamp1">{sub}</span>}
</div>
);
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="glass rounded-panel p-4"
>
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
{title}
</h3>
<div className="flex flex-col gap-2">{children}</div>
</motion.div>
);
}
function Row({ i, label, n }: { i: number; label: string; n: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
className="flex items-center gap-2"
>
<span
className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-faint
text-caption text-dim nums"
>
{i + 1}
</span>
<span className="min-w-0 flex-1 text-callout clamp1">{label}</span>
<span className="text-caption text-dim nums">{n}</span>
</motion.div>
);
}
function Muted({ children }: { children: React.ReactNode }) {
return <span className="text-callout text-dim">{children}</span>;
}
+3 -3
View File
@@ -60,19 +60,19 @@ export function Hero({
)}
<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">
<div className="mb-1.5 text-caption 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>}
{by && <div className="mb-2.5 text-body 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"
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
>
{f}
</span>
+2 -2
View File
@@ -118,7 +118,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
/>
)}
<div className="flex-1 min-w-0">
<div className="text-[13.5px] font-semibold clamp1">{track.title}</div>
<div className="text-callout 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">
@@ -128,7 +128,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
transition={{ ease: "linear", duration: 0.25 }}
/>
</div>
<span className="hidden sm:block text-[11.5px] text-dim tabular-nums shrink-0">
<span className="hidden sm:block text-caption text-dim tabular-nums shrink-0">
{mmss(left)}
</span>
<button
+12 -6
View File
@@ -16,6 +16,9 @@ export type JobsSnapshot = {
const PILL: Record<Job["state"], string> = {
imported: "text-ok border-ok/40 bg-ok/10",
failed: "text-err border-err/40 bg-err/10",
// Held for review, not in flight and not finished — it needs a person, so
// it reads as the accent rather than as another in-progress amber.
review: "text-accent border-accent-line bg-accent-soft",
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",
@@ -31,11 +34,12 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
const failed = counts.failed ?? 0;
const total = active + done;
const held = counts.review ?? 0;
const stats: [string, number, string][] = [
["In flight", active, "text-accent"],
["Imported", done, "text-ok"],
["Held", held, held > 0 ? "text-warn" : ""],
["Failed", failed, "text-err"],
["Waiting", pending, ""],
];
return (
@@ -47,11 +51,13 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
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)}
className={cn("block text-title leading-none nums", colour)}
>
{n}
</motion.b>
<span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span>
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
{label}
</span>
</motion.div>
))}
</div>
@@ -102,8 +108,8 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
</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">
<div className="text-body font-semibold clamp1">{j.title}</div>
<div className="mt-px text-callout text-dim clamp1">
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
</div>
</div>
@@ -125,7 +131,7 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
<span
className={cn(
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
"text-[11px] font-semibold capitalize",
"text-caption font-semibold capitalize",
PILL[j.state] ?? "text-dim border-edge",
)}
>
+215
View File
@@ -0,0 +1,215 @@
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, FileAudio, ShieldQuestion, X } from "lucide-react";
import { api, bytes, type ReviewItem } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { Skeletons } from "@/components/skeletons";
import { cn } from "@/lib/utils";
/**
* Downloads that finished but did not clearly match what was asked for.
*
* The file is already on disk — confidence gates the *import*, not the
* transfer — so approving is instant and rejecting is a delete. Holding the
* download instead would mean the peer has usually vanished by the time
* anyone looks.
*/
export function Review({ onDecided }: { onDecided: () => void }) {
const [items, setItems] = React.useState<ReviewItem[] | null>(null);
const [err, setErr] = React.useState<string | null>(null);
const load = React.useCallback(() => {
api
.review()
.then((r) => setItems(r.items))
.catch((e) => setErr((e as Error).message));
}, []);
React.useEffect(() => {
load();
const t = setInterval(load, 8000);
return () => clearInterval(t);
}, [load]);
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
if (!items) return <Skeletons n={3} />;
if (items.length === 0)
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="px-5 py-16 text-center"
>
<div
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
border border-edge bg-[var(--glass)] text-dim"
>
<Check className="h-6 w-6" />
</div>
<h2 className="text-title">Nothing to review</h2>
<p className="mx-auto mt-1.5 max-w-[44ch] text-callout text-dim">
Anything muse wasn&rsquo;t confident about lands here before it reaches your
library. An empty list means every match was unambiguous.
</p>
</motion.div>
);
return (
<div className="flex flex-col gap-3">
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-callout text-dim"
>
{items.length} download{items.length === 1 ? "" : "s"} finished but didn&rsquo;t
clearly match the request. Each file is already on disk — approving files it
into your library, rejecting deletes it.
</motion.p>
<AnimatePresence initial={false} mode="popLayout">
{items.map((it) => (
<ReviewCard key={it.id} item={it} refresh={load} onDecided={onDecided} />
))}
</AnimatePresence>
</div>
);
}
function ReviewCard({
item,
refresh,
onDecided,
}: {
item: ReviewItem;
refresh: () => void;
onDecided: () => void;
}) {
const toast = useToast();
const [busy, setBusy] = React.useState(false);
const decide = async (action: "approve" | "reject") => {
setBusy(true);
try {
await api.decide(item.id, action);
toast(action === "approve" ? `Filed ${item.title}` : `Deleted ${item.file}`);
refresh();
onDecided();
} catch (e) {
toast((e as Error).message, true);
setBusy(false);
}
};
const pct = Math.round((item.confidence ?? 0) * 100);
const reasons = (item.why ?? "").split(";").map((s) => s.trim()).filter(Boolean);
return (
<motion.div
layout
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
transition={{ type: "spring", stiffness: 340, damping: 30 }}
className="glass rounded-panel p-4 sm:p-5"
>
<div className="flex gap-4">
{item.cover ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={item.cover}
alt=""
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
/>
) : (
<div
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
border border-edge bg-faint text-dim"
>
<FileAudio className="h-6 w-6" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="text-headline clamp1">{item.title}</div>
<div className="text-callout text-dim clamp1">
{item.artist}
{item.album ? ` · ${item.album}` : ""}
{item.year ? ` · ${item.year}` : ""}
</div>
<ConfidenceBar pct={pct} />
</div>
</div>
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
<ShieldQuestion className="h-3 w-3" /> what was found
</div>
<div className="break-all font-mono text-[12px] text-ink">{item.file}</div>
{item.size ? (
<div className="mt-1 text-caption text-dim nums">{bytes(item.size)}</div>
) : null}
{reasons.length > 0 && (
<ul className="mt-2.5 flex flex-wrap gap-1.5">
{reasons.map((r) => (
<li
key={r}
className={cn(
"rounded-pill border px-2 py-0.5 text-caption",
/not |differs|looks like/.test(r)
? "border-warn/40 text-warn"
: "border-edge text-dim",
)}
>
{r}
</li>
))}
</ul>
)}
</div>
<div className="mt-3.5 flex flex-wrap items-center gap-2">
<Button onClick={() => decide("approve")} disabled={busy}>
<Check className="h-3.5 w-3.5" /> Add to library
</Button>
<Button
variant="quiet"
className="border-err/45 text-err"
onClick={() => decide("reject")}
disabled={busy}
>
<X className="h-3.5 w-3.5" /> Delete
</Button>
{item.requested_by && (
<span className="ml-auto text-caption text-dim">
requested by {item.requested_by}
</span>
)}
</div>
</motion.div>
);
}
/** Confidence as a bar rather than a bare number: the useful question is
* "how far below certain is this", which a percentage alone doesn't show. */
function ConfidenceBar({ pct }: { pct: number }) {
const tone = pct >= 85 ? "bg-warn" : pct >= 70 ? "bg-warn" : "bg-err";
return (
<div className="mt-2 flex items-center gap-2.5">
<div className="h-1.5 max-w-[220px] flex-1 overflow-hidden rounded-pill bg-faint">
<motion.i
className={cn("block h-full rounded-pill", tone)}
initial={{ width: 0 }}
animate={{ width: `${pct}%` }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
</div>
<span className="text-caption text-dim nums">{pct}% confident</span>
</div>
);
}
+2 -2
View File
@@ -183,7 +183,7 @@ export function SearchBar({
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">
<div className="px-2.5 pt-2 pb-1 text-caption font-bold uppercase tracking-[0.9px] text-dim">
{g.title}
</div>
{rows.map((it) => {
@@ -218,7 +218,7 @@ export function SearchBar({
<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>}
{it.meta && <div className="shrink-0 text-caption text-dim">{it.meta}</div>}
</div>
);
})}
+171
View File
@@ -0,0 +1,171 @@
"use client";
import * as React from "react";
import { motion } from "framer-motion";
import { Bell, BellOff, Mail } from "lucide-react";
import { api, type Me } from "@/lib/api";
import { useToast } from "@/components/toast";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/** Your own notification settings. Not behind admin on purpose: an address
* you cannot change yourself turns into someone else's chore. */
export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
const toast = useToast();
const [email, setEmail] = React.useState(me.email ?? "");
const [notify, setNotify] = React.useState(me.notify ?? true);
const [busy, setBusy] = React.useState(false);
const dirty = email !== (me.email ?? "") || notify !== (me.notify ?? true);
const save = async () => {
setBusy(true);
try {
await api.settings({ email: email.trim(), notify });
toast("Settings saved");
refresh();
} catch (e) {
toast((e as Error).message, true);
}
setBusy(false);
};
const googleImplicit = me.provider === "google" && !me.email;
// MUSE_AUTH=off means there is no account to attach a preference to, and
// PATCH /api/me would 401. Say so rather than offering a form that fails.
const anonymous = !me.user;
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col gap-4"
>
{anonymous && (
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
Authentication is turned off on this instance, so there is no account to
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
to <code className="font-mono text-[12px]">github</code> or{" "}
<code className="font-mono text-[12px]">google</code> to enable sign-in.
</div>
)}
<section className="glass rounded-panel p-5">
<div className="mb-1 flex items-center gap-2">
<Mail className="h-4 w-4 text-accent" />
<h2 className="text-headline">Notifications</h2>
</div>
<p className="mb-4 max-w-[58ch] text-callout text-dim">
muse can email you when a request lands in your library, or when a download
finished but needs your call before it&rsquo;s filed.
</p>
{!me.mail_configured && (
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
No SMTP server is configured on this instance, so nothing will actually be
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
</div>
)}
<label className="flex max-w-md flex-col gap-1.5">
<span className="text-caption uppercase tracking-[0.7px] text-dim">
Where to write
</span>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
disabled={anonymous}
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2
text-body outline-none transition focus:border-accent-line
disabled:opacity-45"
/>
{googleImplicit && (
<span className="text-caption text-dim">
Blank uses your Google address, {me.user}.
</span>
)}
</label>
<button
onClick={() => setNotify(!notify)}
role="switch"
aria-checked={notify}
disabled={anonymous}
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
text-left disabled:cursor-default disabled:opacity-45"
>
<span
className={cn(
"relative h-[26px] w-[44px] shrink-0 rounded-pill border transition-colors",
notify ? "border-accent-line bg-accent-soft" : "border-edge bg-faint",
)}
>
<motion.span
layout
transition={{ type: "spring", stiffness: 520, damping: 34 }}
className={cn(
"absolute top-[2px] h-[20px] w-[20px] rounded-full",
notify ? "left-[21px] bg-accent" : "left-[2px] bg-dim",
)}
/>
</span>
<span className="flex items-center gap-1.5 text-body">
{notify ? (
<Bell className="h-3.5 w-3.5 text-accent" />
) : (
<BellOff className="h-3.5 w-3.5 text-dim" />
)}
{notify ? "Email me about my requests" : "Don't email me"}
</span>
</button>
<div className="mt-5">
<Button onClick={save} disabled={!dirty || busy || anonymous}>
{busy ? "Saving…" : "Save"}
</Button>
</div>
</section>
<section className="glass rounded-panel p-5">
<h2 className="mb-3 text-headline">Account</h2>
{anonymous ? (
<p className="text-callout text-dim">
Nobody is signed in — every request is anonymous and every role check
reports admin.
</p>
) : (
<dl className="grid gap-x-6 gap-y-2 text-callout sm:grid-cols-[auto_1fr]">
<Field k="Signed in as" v={me.display || me.user || "—"} />
<Field k="Identity" v={`${me.user} · ${me.provider ?? "—"}`} />
<Field k="Role" v={me.role ?? "—"} />
<Field
k="Requests this month"
v={
me.quota
? `${me.usage?.requests ?? 0} of ${me.quota}`
: `${me.usage?.requests ?? 0} · no limit`
}
/>
</dl>
)}
<p className="mt-4 max-w-[58ch] text-caption text-dim">
muse is the front door. Playback, playlists and listening history live in
Navidrome — this only decides what gets into the library.
</p>
</section>
</motion.div>
);
}
function Field({ k, v }: { k: string; v: string }) {
return (
<>
<dt className="text-dim">{k}</dt>
<dd className="clamp1">{v}</dd>
</>
);
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
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",
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
"max-w-[calc(100vw-32px)]",
t.err && "border-err/50 text-err",
)}
+4 -4
View File
@@ -79,7 +79,7 @@ export function TrackRow({
)}
>
{showNumber && (
<div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums">
<div className="w-6 shrink-0 text-right text-callout text-dim tabular-nums">
{track.track ?? ""}
</div>
)}
@@ -121,7 +121,7 @@ export function TrackRow({
</button>
<div className="flex-1 min-w-0">
<div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1">
<div className="text-body 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">
@@ -129,7 +129,7 @@ export function TrackRow({
</span>
)}
</div>
<div className="mt-px text-[12.5px] text-dim clamp1">
<div className="mt-px text-callout text-dim clamp1">
{showArtist && track.artist_id && onArtist ? (
<button
onClick={() => onArtist(track.artist_id!)}
@@ -149,7 +149,7 @@ export function TrackRow({
</div>
</div>
<div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums">
<div className="hidden sm:block shrink-0 text-callout text-dim tabular-nums">
{track.length ? mmss(track.length) : ""}
</div>
+6 -4
View File
@@ -16,7 +16,9 @@ const button = cva(
// 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]",
primary:
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
"active:scale-[0.97] active:brightness-95",
ghost:
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
outline:
@@ -25,9 +27,9 @@ const button = cva(
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",
sm: "text-caption px-3 py-1.5 rounded-pill",
md: "text-callout px-4.5 py-2.5 rounded-control",
pill: "text-callout px-4 py-2 rounded-pill",
},
},
defaultVariants: { variant: "primary", size: "md" },