Recover blocked searches, fall through to the next peer, rebuild the UI
Measured against a live instance: 47 failed jobs against 33 imported. Three independent causes, all of which ended the request. The Soulseek server filters some artist names. "Michael Jackson Billie Jean" and bare "Michael Jackson" both return zero responses; "Billie Jean" returns 1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since pick_and_enqueue sent exactly one query of `artist title`, every request for a filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences. search_queries() now walks a ladder, loosest last, ending at the bare title, and escalates only when a search underdelivers so the common case still costs one search. Dropping the artist from the query does not mean ignoring it: match_ratio() looks for it in the folder path, where an organised share puts it. A rejected transfer ended the request. pick_and_enqueue kept a running maximum and discarded every runner-up, so "Transfer rejected: File not shared." -- a per-file condition where candidate two almost always works -- was terminal, as were timeouts and slskd's 500 on an unreachable peer. The ranked list is now persisted on the job row, ordered to put a different peer next, and try_next_candidate() advances through it. A job fails only once the list is exhausted, and then reports every peer it tried and what each one said. norm() destroyed every non-ASCII character, so "Beyonce" never matched Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops apostrophes while leaving & as a separator. Also: availability outranks format in score(), because a FLAC behind 5000 queued users used to beat an immediately available MP3; transfers that stall or that slskd forgets are abandoned instead of sitting in `downloading` forever; searches are deleted only once finished, since deleting one mid-flight makes slskd throw an optimistic-concurrency error and lose the record; a failed search POST is retried rather than failing the job on one blip. The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with neutral chrome and the sampled artwork colour confined to hero areas rather than repainting the whole interface. Adds URL routing, a primitive set under components/ui, and a request sheet showing the attempt ladder. Fixes hover:bg-glass-hi, which was never a valid class, so track rows and the search field had no hover or focus state at all; a reduced-motion path that could strand elements at opacity 0; the login page's hardcoded palette; and the missing 404 and error boundaries. tests/test_acquisition.py covers this path against a faked slskd. It had none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0dca779f59
commit
171e117f7c
47 files changed
+2939
-805
No files matched your search
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/l
|
||||
import { StatCard } from "@/components/admin/stat-card";
|
||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonCards } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminPanel({ me }: { me: string | null }) {
|
||||
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
|
||||
}, [loadUsers]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!sys || !users) return <Skeletons n={6} />;
|
||||
if (!sys || !users) return <SkeletonCards n={4} />;
|
||||
|
||||
const s = sys.system;
|
||||
const lib = s.disks.library;
|
||||
@@ -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-callout",
|
||||
"card flex items-center gap-2.5 rounded-card px-4 py-2.5 text-callout",
|
||||
healthy ? "border-ok/30" : "border-err/40",
|
||||
)}
|
||||
>
|
||||
@@ -141,7 +141,7 @@ function Breakdown({
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<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) => (
|
||||
|
||||
@@ -26,7 +26,7 @@ export function StatCard({
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||
className="glass rounded-glass p-3.5"
|
||||
className="card rounded-card p-3.5"
|
||||
>
|
||||
<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]">
|
||||
|
||||
@@ -22,7 +22,7 @@ export function UsersTable({
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
@@ -35,7 +35,7 @@ export function UsersTable({
|
||||
anyone in — the row is.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
<Button size="pill" variant={adding ? "secondary" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{adding ? "Cancel" : "Invite"}
|
||||
</Button>
|
||||
@@ -101,7 +101,7 @@ function UserRow({
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
className={cn(
|
||||
"glass-hi rounded-glass p-3",
|
||||
"card rounded-card p-3",
|
||||
u.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
};
|
||||
|
||||
const input =
|
||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"text-ink outline-none focus:border-accent-line";
|
||||
|
||||
return (
|
||||
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
||||
<div className="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
|
||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||
<span className="text-caption text-dim">
|
||||
{provider === "github" ? "GitHub username" : "Google email"}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"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";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { AlertCircle, Disc } from "lucide-react";
|
||||
|
||||
export function AlbumView({
|
||||
id,
|
||||
@@ -43,8 +44,16 @@ export function AlbumView({
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (err)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
tone="err"
|
||||
title="Could not load album"
|
||||
body={err}
|
||||
/>
|
||||
);
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { album, tracks } = data;
|
||||
|
||||
@@ -88,29 +97,42 @@ export function AlbumView({
|
||||
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>
|
||||
// Hidden rather than rendered as "Get all 0 tracks": Deezer serves
|
||||
// metadata for albums whose track list it has no data for, and an
|
||||
// enabled button that queues nothing is worse than no button.
|
||||
tracks.length > 0 ? (
|
||||
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||
{batch === "idle"
|
||||
? `Get all ${tracks.length} tracks`
|
||||
: batch === "busy"
|
||||
? "Queueing…"
|
||||
: `Queued ${queued}`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
{tracks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Disc}
|
||||
title="Deezer has no track list for this release"
|
||||
body="Its catalogue has the album but not its songs. Search for a track by name instead."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
track={t}
|
||||
index={i}
|
||||
showNumber
|
||||
showArtist={false}
|
||||
showAlbum={false}
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export function ArtistView({
|
||||
id,
|
||||
@@ -42,7 +42,7 @@ export function ArtistView({
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { artist, tracks, albums } = data;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ArtistView({
|
||||
/>
|
||||
|
||||
<SectionHead>Top tracks</SectionHead>
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<motion.div layout className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"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]" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-5"
|
||||
className="card rounded-panel p-5"
|
||||
>
|
||||
<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">
|
||||
@@ -61,7 +61,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={PLACEHOLDER}
|
||||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||||
className="min-h-[170px] w-full resize-y rounded-card 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
|
||||
|
||||
@@ -5,7 +5,7 @@ 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";
|
||||
import { SkeletonGrid } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Recommendations and a personal recap, both derived from muse's OWN request
|
||||
@@ -24,7 +24,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
api.recap().then(setR).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
if (!d) return <Skeletons n={4} />;
|
||||
if (!d) return <SkeletonGrid n={8} />;
|
||||
|
||||
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
||||
if (empty)
|
||||
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
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"
|
||||
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
|
||||
>
|
||||
{a.picture ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-4"
|
||||
className="card rounded-panel p-4"
|
||||
>
|
||||
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{title}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Hero({
|
||||
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
|
||||
className="card 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 && (
|
||||
@@ -72,7 +72,7 @@ export function Hero({
|
||||
{facts.filter(Boolean).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
className="card rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckCircle2, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
import { Sheet } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/toast";
|
||||
|
||||
/**
|
||||
* Why a request is where it is.
|
||||
*
|
||||
* Previously a failure was a red word in a list plus a three-second toast, and
|
||||
* the reason was raw text — "transfer Completed, Errored" — joined with a dot
|
||||
* after the album name in 13px dim type. Since the backend now walks a ladder
|
||||
* of candidates instead of giving up on the first peer, there is an actual
|
||||
* story to tell: who was tried, what each one said, and what is left.
|
||||
*/
|
||||
export function JobSheet({
|
||||
job,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
job: Job | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const retry = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.retry(job.id);
|
||||
toast("Requeued");
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const conf = job?.confidence;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={!!job}
|
||||
onOpenChange={(v) => !v && onClose()}
|
||||
title={job ? job.title : ""}
|
||||
description={job ? [job.artist, job.album].filter(Boolean).join(" · ") : undefined}
|
||||
footer={
|
||||
job?.state === "failed" ? (
|
||||
<Button onClick={retry} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} Try again
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{job && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatePill state={job.state} />
|
||||
{job.attempts > 0 && (
|
||||
<span className="text-caption text-mute nums">
|
||||
{job.attempts} peer{job.attempts === 1 ? "" : "s"} tried
|
||||
{job.state !== "failed" && job.remaining > 0
|
||||
? ` · ${job.remaining} left to try`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.detail && (
|
||||
<p className="text-callout text-dim">{job.detail}</p>
|
||||
)}
|
||||
|
||||
{conf != null && (
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<h3 className="text-callout font-medium">Match confidence</h3>
|
||||
<span className="text-callout text-dim nums">{Math.round(conf * 100)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={conf * 100}
|
||||
tone={conf >= 0.95 ? "ok" : conf >= 0.7 ? "warn" : "err"}
|
||||
/>
|
||||
{job.match_why && (
|
||||
<p className="mt-1.5 text-caption text-mute">{job.match_why}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.remote_file && (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-callout font-medium">File</h3>
|
||||
<p className="break-all rounded-control bg-[var(--surface-2)] p-2.5 font-mono text-caption text-dim">
|
||||
{job.remote_file.replace(/\\/g, "/")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.tried.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-callout font-medium">
|
||||
Peers tried
|
||||
</h3>
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{job.tried.map((t, i) => (
|
||||
<li
|
||||
key={`${t.username}-${i}`}
|
||||
className="flex items-start gap-2 rounded-control border border-edge
|
||||
bg-[var(--surface-2)] px-2.5 py-2"
|
||||
>
|
||||
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-err" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{t.username}</div>
|
||||
<div className="text-caption text-dim">{t.reason}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{job.state === "downloading" && job.username && (
|
||||
<li
|
||||
className="flex items-start gap-2 rounded-control border border-accent-line
|
||||
bg-accent-soft px-2.5 py-2"
|
||||
>
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{job.username}</div>
|
||||
<div className="text-caption text-dim">downloading now</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StatePill({ state }: { state: Job["state"] }) {
|
||||
const map = {
|
||||
imported: { tone: "ok", icon: CheckCircle2, label: "In your library" },
|
||||
failed: { tone: "err", icon: XCircle, label: "Failed" },
|
||||
review: { tone: "accent", icon: CheckCircle2, label: "Waiting on you" },
|
||||
downloading: { tone: "warn", icon: Loader2, label: "Downloading" },
|
||||
searching: { tone: "warn", icon: Loader2, label: "Searching Soulseek" },
|
||||
queued: { tone: "neutral", icon: Loader2, label: "Queued" },
|
||||
} as const;
|
||||
const m = map[state];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Badge tone={m.tone}>
|
||||
<Icon className={`h-3 w-3 ${state === "imported" || state === "failed" ? "" : "animate-spin"}`} />
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Stop preview"
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
|
||||
text-ink cursor-pointer transition hover:border-accent-line
|
||||
hover:text-accent"
|
||||
>
|
||||
|
||||
+103
-110
@@ -2,9 +2,14 @@
|
||||
|
||||
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 { Inbox, ChevronRight } from "lucide-react";
|
||||
import { type Job } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { JobSheet } from "@/components/job-sheet";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
@@ -13,142 +18,130 @@ export type JobsSnapshot = {
|
||||
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",
|
||||
// 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",
|
||||
/**
|
||||
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||
* differed only by the word inside, so the three stages of a request looked
|
||||
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||
* now read as distinct.
|
||||
*/
|
||||
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||
queued: { tone: "neutral", label: "Queued" },
|
||||
searching: { tone: "accent", label: "Searching", live: true },
|
||||
downloading: { tone: "warn", label: "Downloading", live: true },
|
||||
review: { tone: "accent", label: "Needs review" },
|
||||
imported: { tone: "ok", label: "In library" },
|
||||
failed: { tone: "err", label: "Failed" },
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
const ok = useMotionOK();
|
||||
const [open, setOpen] = React.useState<string | null>(null);
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const { jobs, counts } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const held = counts.review ?? 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"],
|
||||
["In library", done, "text-ok"],
|
||||
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
|
||||
];
|
||||
|
||||
// Kept in sync with the poll so the sheet updates live while it is open.
|
||||
const current = jobs.find((j) => j.id === open) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
{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-title leading-none nums", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
|
||||
<Card key={label} pad="sm">
|
||||
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||
{label}
|
||||
</span>
|
||||
</motion.div>
|
||||
</Card>
|
||||
))}
|
||||
</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>
|
||||
{active > 0 && total > 0 && (
|
||||
<Progress
|
||||
value={(done / total) * 100}
|
||||
className="mb-4"
|
||||
label="Requests completed"
|
||||
/>
|
||||
)}
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Nothing requested yet"
|
||||
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
<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-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>
|
||||
|
||||
{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-caption font-semibold capitalize",
|
||||
PILL[j.state] ?? "text-dim border-edge",
|
||||
)}
|
||||
{jobs.map((j) => {
|
||||
const s = STATE[j.state] ?? STATE.queued;
|
||||
return (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
onClick={() => setOpen(j.id)}
|
||||
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
|
||||
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
|
||||
text-left transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
{(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>
|
||||
))}
|
||||
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
|
||||
{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-body font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-callout text-dim clamp1">
|
||||
{[j.artist, j.album].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{j.detail && j.state === "failed" && (
|
||||
<div className="mt-0.5 text-caption text-err clamp1">{j.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge tone={s.tone} className="shrink-0">
|
||||
{s.live && (
|
||||
<motion.span
|
||||
className="h-[5px] w-[5px] rounded-full bg-current"
|
||||
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ 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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
}, [load]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!items) return <Skeletons n={3} />;
|
||||
if (!items) return <div className="flex flex-col gap-3">{[0,1,2].map((i)=>(<Skeleton key={i} className="h-[196px] rounded-panel" />))}</div>;
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
>
|
||||
<div
|
||||
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
||||
border border-edge bg-[var(--glass)] text-dim"
|
||||
border border-edge bg-[var(--surface)] text-dim"
|
||||
>
|
||||
<Check className="h-6 w-6" />
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ function ReviewCard({
|
||||
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"
|
||||
className="card rounded-panel p-4 sm:p-5"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{item.cover ? (
|
||||
@@ -123,11 +123,11 @@ function ReviewCard({
|
||||
<img
|
||||
src={item.cover}
|
||||
alt=""
|
||||
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
|
||||
className="h-16 w-16 shrink-0 rounded-card border border-edge object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-card
|
||||
border border-edge bg-faint text-dim"
|
||||
>
|
||||
<FileAudio className="h-6 w-6" />
|
||||
@@ -145,7 +145,7 @@ function ReviewCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
|
||||
<div className="mt-3.5 rounded-card border border-edge bg-[var(--surface)] 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>
|
||||
|
||||
@@ -9,19 +9,36 @@ 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({
|
||||
initial,
|
||||
busy,
|
||||
onSearch,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
}: {
|
||||
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
||||
initial?: string;
|
||||
busy?: boolean;
|
||||
onSearch: (q: string) => void;
|
||||
onAlbum: (id: number) => void;
|
||||
onArtist: (id: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = React.useState("");
|
||||
const [q, setQ] = React.useState(initial ?? "");
|
||||
// Follows the URL on Back/Forward, but never fights what is being typed:
|
||||
// only a genuinely different incoming query replaces the field.
|
||||
const seeded = React.useRef(initial);
|
||||
React.useEffect(() => {
|
||||
if (initial !== undefined && initial !== seeded.current) {
|
||||
seeded.current = initial;
|
||||
typed.current = false;
|
||||
setQ(initial);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [initial]);
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// A value seeded from the URL must not pop the suggestion list open over the
|
||||
// results that link was for. Only typing counts as asking for suggestions.
|
||||
const typed = React.useRef(false);
|
||||
const [sel, setSel] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -76,7 +93,7 @@ export function SearchBar({
|
||||
];
|
||||
setItems(next);
|
||||
setSel(-1);
|
||||
setOpen(next.length > 0);
|
||||
setOpen(typed.current && next.length > 0);
|
||||
} catch {
|
||||
/* aborted or offline — leave the previous list alone */
|
||||
}
|
||||
@@ -129,9 +146,9 @@ export function SearchBar({
|
||||
<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",
|
||||
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-out",
|
||||
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||
)}
|
||||
>
|
||||
@@ -140,7 +157,10 @@ export function SearchBar({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
typed.current = true;
|
||||
setQ(e.target.value);
|
||||
}}
|
||||
onKeyDown={key}
|
||||
onFocus={() => items.length && setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||
@@ -161,7 +181,7 @@ export function SearchBar({
|
||||
}}
|
||||
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"
|
||||
card text-dim hover:text-ink cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{anonymous && (
|
||||
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||
<div className="card 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">
|
||||
<section className="card 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>
|
||||
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</p>
|
||||
|
||||
{!me.mail_configured && (
|
||||
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
|
||||
<div className="mb-4 rounded-card border border-warn/40 bg-[var(--surface)] 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.
|
||||
@@ -79,7 +79,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
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
|
||||
className="focus-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"
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
role="switch"
|
||||
aria-checked={notify}
|
||||
disabled={anonymous}
|
||||
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
|
||||
className="focus-ring mt-4 flex cursor-pointer items-center gap-3 rounded-card
|
||||
text-left disabled:cursor-default disabled:opacity-45"
|
||||
>
|
||||
<span
|
||||
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-panel p-5">
|
||||
<section className="card rounded-panel p-5">
|
||||
<h2 className="mb-3 text-headline">Account</h2>
|
||||
{anonymous ? (
|
||||
<p className="text-callout text-dim">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
ListMusic,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
Settings2,
|
||||
Gauge,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Tab } from "@/lib/nav";
|
||||
|
||||
export type NavItem = {
|
||||
tab: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: "Library" | "Activity" | "Manage";
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped, because a flat list of seven is a menu and a grouped list of seven
|
||||
* is a structure. The order is the order of use: you search, you watch what
|
||||
* you asked for, you administer occasionally.
|
||||
*/
|
||||
export const NAV: NavItem[] = [
|
||||
{ tab: "search", label: "Search", icon: Search, group: "Library" },
|
||||
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
|
||||
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
|
||||
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
|
||||
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
|
||||
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
|
||||
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
|
||||
];
|
||||
|
||||
/** The five that earn a slot on a phone's bottom bar. */
|
||||
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Music2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { NAV } from "./nav-items";
|
||||
|
||||
/**
|
||||
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
|
||||
* Settings. It replaces a horizontally scrolling row of pills, which is a
|
||||
* mobile-web affordance that had been left on the desktop layout too.
|
||||
*
|
||||
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
|
||||
* would eat a third of the window.
|
||||
*/
|
||||
export function Sidebar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
rail,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
rail: boolean;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const groups = ["Library", "Activity", "Manage"] as const;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className={cn(
|
||||
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
|
||||
"md:flex",
|
||||
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
|
||||
<Music2 className="h-4 w-4" />
|
||||
</span>
|
||||
{!rail && <span className="text-headline tracking-tight">muse</span>}
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const items = NAV.filter((n) => n.group === g && visible(n.tab));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g} className="mb-2 w-full">
|
||||
{!rail && (
|
||||
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
|
||||
{g}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<li key={tab}>
|
||||
<a
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
title={rail ? label : undefined}
|
||||
onClick={(e) => {
|
||||
// Left-click navigates in place; cmd/ctrl-click still
|
||||
// opens a real new tab, because these are real links.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex items-center rounded-control text-callout",
|
||||
"font-medium transition-colors duration-150",
|
||||
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
|
||||
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<motion.span
|
||||
layoutId="nav-selection"
|
||||
transition={ok ? spring : { duration: 0 }}
|
||||
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative h-4 w-4 shrink-0" />
|
||||
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-pill text-caption font-bold nums",
|
||||
rail
|
||||
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
|
||||
: "relative px-1.5",
|
||||
tab === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertTriangle, WifiOff } from "lucide-react";
|
||||
import { api, type Health } from "@/lib/api";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* System health, for everyone.
|
||||
*
|
||||
* This check already existed but lived inside the admin panel, so a regular
|
||||
* user whose every request was failing saw nothing but a growing pile of red
|
||||
* pills. "Reachable but not logged in" is the important case: the container is
|
||||
* up and healthy, so no other signal reports a problem, yet every search
|
||||
* returns nothing.
|
||||
*/
|
||||
export function StatusBanner() {
|
||||
const [h, setH] = React.useState<Health | null>(null);
|
||||
const ok = useMotionOK();
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api.health();
|
||||
if (live) setH(r);
|
||||
} catch {
|
||||
/* a 401 here just means the poll outlived the session */
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const t = setInterval(poll, 20000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const problem = !h
|
||||
? null
|
||||
: h.slskd !== 200
|
||||
? {
|
||||
icon: WifiOff,
|
||||
text: "Can't reach slskd — nothing will download until it's back.",
|
||||
}
|
||||
: h.logged_in === false
|
||||
? {
|
||||
icon: AlertTriangle,
|
||||
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
|
||||
}
|
||||
: h.library_writable === false
|
||||
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{problem && (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={ok ? { height: 0, opacity: 0 } : false}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
|
||||
<problem.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="clamp1">{problem.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { NAV, MOBILE } from "./nav-items";
|
||||
|
||||
/**
|
||||
* The phone layout. A bottom bar is what a native app does, and it puts the
|
||||
* targets under the thumb instead of at the top of a tall screen. Respects the
|
||||
* home-indicator inset, which the previous layout did not.
|
||||
*/
|
||||
export function TabBar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
|
||||
pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
>
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<a
|
||||
key={tab}
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
|
||||
"text-caption font-medium transition-colors",
|
||||
on ? "text-accent" : "text-mute",
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="h-5 w-5" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
|
||||
"font-bold leading-[15px] nums",
|
||||
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Me } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Sticky chrome over scrolling content: search on the left, identity on the
|
||||
* right. Everything that used to be hidden below `sm:` — the quota chip, the
|
||||
* display name — now degrades into the overflow rather than vanishing, since
|
||||
* "how many requests do I have left" matters most on the device you are
|
||||
* actually queueing from.
|
||||
*/
|
||||
export function Toolbar({
|
||||
me,
|
||||
quotaLeft,
|
||||
children,
|
||||
}: {
|
||||
me: Me | null;
|
||||
quotaLeft: number | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="material sticky top-0 z-30 border-b border-edge">
|
||||
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
|
||||
{me?.user && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{quotaLeft !== null && (
|
||||
<Badge
|
||||
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className="nums"
|
||||
>
|
||||
{quotaLeft} left
|
||||
</Badge>
|
||||
)}
|
||||
{me.role && me.role !== "user" && (
|
||||
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
|
||||
{me.role}
|
||||
</Badge>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-7 w-7 rounded-full border border-edge object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
title="Log out"
|
||||
aria-label="Log out"
|
||||
className={cn(
|
||||
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
|
||||
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+70
-16
@@ -2,43 +2,97 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
|
||||
type Toast = { id: number; msg: string; err?: boolean };
|
||||
type Ctx = (msg: string, err?: boolean) => void;
|
||||
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
|
||||
type Action = { label: string; run: () => void };
|
||||
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
|
||||
|
||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||
export const useToast = () => React.useContext(ToastCtx);
|
||||
|
||||
/** An error you cannot read in time is the same as no error at all. */
|
||||
const DWELL_OK = 3400;
|
||||
const DWELL_ERR = 7000;
|
||||
/** Beyond this the stack is a wall, and the oldest is the least relevant. */
|
||||
const MAX = 3;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = React.useState<Toast[]>([]);
|
||||
const ok = useMotionOK();
|
||||
|
||||
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);
|
||||
}, []);
|
||||
const drop = React.useCallback(
|
||||
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
|
||||
[],
|
||||
);
|
||||
|
||||
const push = React.useCallback<Ctx>(
|
||||
(msg, err, action) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((x) => [...x, { id, msg, err, action }].slice(-MAX));
|
||||
setTimeout(() => drop(id), err ? DWELL_ERR : DWELL_OK);
|
||||
},
|
||||
[drop],
|
||||
);
|
||||
|
||||
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">
|
||||
{/*
|
||||
aria-live so a screen reader hears "Queued" or a failure at all — these
|
||||
were previously silent, which made the only confirmation that a request
|
||||
had been accepted a purely visual one.
|
||||
*/}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-24 z-[110] flex flex-col
|
||||
items-center gap-2 px-4 md:bottom-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{items.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
className={cn(
|
||||
"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",
|
||||
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
|
||||
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
|
||||
t.err && "border-err/45",
|
||||
)}
|
||||
>
|
||||
{t.msg}
|
||||
{t.err ? (
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-err" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1", t.err && "text-err")}>{t.msg}</span>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.run();
|
||||
drop(t.id);
|
||||
}}
|
||||
className="focus-ring shrink-0 rounded-pill px-2.5 py-0.5 text-callout
|
||||
font-semibold text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => drop(t.id)}
|
||||
aria-label="Dismiss"
|
||||
className="focus-ring grid h-6 w-6 shrink-0 place-items-center rounded-full
|
||||
text-mute transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -73,8 +73,8 @@ export function TrackRow({
|
||||
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",
|
||||
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
|
||||
isPlaying && "border-accent-line bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badge = cva(
|
||||
"inline-flex items-center gap-1 rounded-pill border px-2 py-0.5 " +
|
||||
"text-caption font-medium whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: "border-edge text-dim",
|
||||
accent: "border-accent-line bg-accent-soft text-accent",
|
||||
ok: "border-ok/40 bg-ok/10 text-ok",
|
||||
warn: "border-warn/40 bg-warn/10 text-warn",
|
||||
err: "border-err/40 bg-err/10 text-err",
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: "neutral" },
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
tone,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badge>) {
|
||||
return <span className={cn(badge({ tone }), className)} {...props} />;
|
||||
}
|
||||
@@ -6,30 +6,36 @@ 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",
|
||||
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
|
||||
"select-none whitespace-nowrap focus-ring " +
|
||||
"transition-[background,border-color,color,transform,box-shadow] " +
|
||||
"duration-150 ease-out active:scale-[0.97] " +
|
||||
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
|
||||
"disabled:active:scale-100",
|
||||
{
|
||||
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] 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]",
|
||||
// The accent is fixed now, not sampled from artwork, so a solid button
|
||||
// has a known contrast ratio and can carry a plain white label. It
|
||||
// used to be near-black text on a colour taken from the album cover,
|
||||
// which only worked because colour.ts clamped the luminance.
|
||||
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
|
||||
secondary:
|
||||
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
|
||||
"hover:bg-[var(--surface-2)]",
|
||||
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",
|
||||
"bg-transparent border border-edge-hi text-ink " +
|
||||
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
|
||||
quiet: "bg-transparent text-dim hover:text-ink hover:bg-[var(--faint)]",
|
||||
danger: "bg-transparent border border-err/45 text-err hover:bg-err/10",
|
||||
},
|
||||
size: {
|
||||
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",
|
||||
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
|
||||
sm: "text-callout px-3 py-1.5 rounded-control",
|
||||
md: "text-callout px-4 py-2 rounded-control",
|
||||
lg: "text-body px-5 py-2.5 rounded-control font-semibold",
|
||||
icon: "h-8 w-8 rounded-control",
|
||||
pill: "text-callout px-4 py-1.5 rounded-pill",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "md" },
|
||||
@@ -45,9 +51,7 @@ export interface ButtonProps
|
||||
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} />
|
||||
);
|
||||
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The content surface. `"card rounded-panel p-5"` was copy-pasted across eight
|
||||
* files with the padding drifting between p-3, p-3.5, p-4 and p-5; this fixes
|
||||
* the scale in one place.
|
||||
*/
|
||||
export function Card({
|
||||
className,
|
||||
pad = "md",
|
||||
interactive,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
pad?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"card rounded-panel",
|
||||
{ none: "", sm: "p-3", md: "p-4", lg: "p-5 sm:p-6" }[pad],
|
||||
interactive && "lift cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mb-3 flex items-center justify-between gap-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("text-headline", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardNote({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-callout text-dim", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Seven screens rendered a bare centred string for "nothing here" and two had
|
||||
* a real designed state. This is the designed one, made reusable.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
tone = "neutral",
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
tone?: "neutral" | "err";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center px-6 py-14 text-center", className)}>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 grid h-12 w-12 place-items-center rounded-panel border",
|
||||
tone === "err"
|
||||
? "border-err/35 bg-err/10 text-err"
|
||||
: "border-edge bg-[var(--surface-2)] text-mute",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className={cn("text-headline", tone === "err" && "text-err")}>{title}</h3>
|
||||
{body && <p className="mt-1.5 max-w-[42ch] text-callout text-dim">{body}</p>}
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Shared shell for every text control, so focus looks the same everywhere. */
|
||||
export const controlBase =
|
||||
"w-full rounded-control border border-edge bg-[var(--surface-2)] px-3 py-2 " +
|
||||
"text-body text-ink outline-none placeholder:text-mute " +
|
||||
"transition-[border-color,box-shadow,background] duration-150 ease-out " +
|
||||
"focus:border-accent-line focus:bg-[var(--surface)] " +
|
||||
"focus:shadow-[0_0_0_3px_var(--accent-soft)] " +
|
||||
"disabled:opacity-45 disabled:cursor-not-allowed";
|
||||
|
||||
export const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cn(controlBase, className)} {...props} />
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea ref={ref} className={cn(controlBase, "resize-y leading-relaxed", className)} {...props} />
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
/**
|
||||
* Options inherit the surface token rather than a hardcoded `#12141c`, which
|
||||
* previously rendered dark dropdown items on a white page in light mode.
|
||||
*/
|
||||
export const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<select ref={ref} className={cn(controlBase, "cursor-pointer pr-8", className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className="text-callout font-medium text-dim">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-caption text-mute">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One progress bar. There were six hand-rolled ones with four different
|
||||
* heights, while @radix-ui/react-progress sat installed and unimported.
|
||||
*/
|
||||
export function Progress({
|
||||
value,
|
||||
tone = "accent",
|
||||
size = "md",
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number; // 0..100
|
||||
tone?: "accent" | "ok" | "warn" | "err" | "art";
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-pill bg-[var(--surface-3)]",
|
||||
size === "sm" ? "h-1" : "h-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-pill transition-[width] duration-500 ease-out",
|
||||
{ accent: "bg-accent", ok: "bg-ok", warn: "bg-warn", err: "bg-err", art: "bg-art" }[tone],
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* A real modal, on Radix, which was already a dependency and never imported.
|
||||
* That matters beyond tidiness: it brings focus trapping, restore-on-close,
|
||||
* Escape, scroll locking and aria wiring that hand-rolled inline confirms
|
||||
* (the previous pattern) do not have.
|
||||
*
|
||||
* Presents as a bottom sheet on phones and a centred panel from `sm:` up.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
|
||||
// Radix locks scrolling by injecting a <style> element, which this app's CSP
|
||||
// (style-src 'self', no 'unsafe-inline') blocks outright — the console says
|
||||
// so every time a sheet opens. Setting the property through the CSSOM is not
|
||||
// markup, so it is allowed, and it is the half that actually matters:
|
||||
// without it the page behind the sheet scrolls under your finger on a phone.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: ok ? 0.18 : 0 }}
|
||||
className="fixed inset-0 z-[100] bg-[var(--scrim)] backdrop-blur-[2px]"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content asChild forceMount>
|
||||
<motion.div
|
||||
initial={ok ? { opacity: 0, y: 24, scale: 0.98 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={ok ? { opacity: 0, y: 16, scale: 0.98 } : { opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className={cn(
|
||||
"sheet fixed z-[101] flex flex-col overflow-hidden",
|
||||
"inset-x-0 bottom-0 max-h-[86vh] rounded-t-sheet rounded-b-none",
|
||||
"sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2",
|
||||
"sm:max-h-[82vh] sm:w-[min(560px,calc(100vw-32px))]",
|
||||
"sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-sheet",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-edge px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title className="text-headline">{title}</Dialog.Title>
|
||||
{description && (
|
||||
<Dialog.Description className="mt-0.5 text-callout text-dim">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center
|
||||
rounded-control text-dim transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shape-aware. There was one fixed 68px row used for the admin dashboard, the
|
||||
* discover grid and the review queue alike, so the loading state never
|
||||
* resembled what actually arrived.
|
||||
*/
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("shimmer rounded-card border border-edge", className)} />;
|
||||
}
|
||||
|
||||
export function SkeletonRows({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-row">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[64px]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCards({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[84px] rounded-panel" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ n = 8 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full rounded-panel" />
|
||||
<Skeleton className="h-3 w-3/4 rounded-pill border-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** CSS-driven, so the reduced-motion block in globals.css actually stops it. */
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 shrink-0 rounded-full border-2 border-current",
|
||||
"border-r-transparent [animation:spin_0.7s_linear_infinite]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { item, useMotionOK } from "@/lib/motion";
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
|
||||
/**
|
||||
* The first screen.
|
||||
*
|
||||
* It used to be a floating disc, a heading, and five hardcoded example chips —
|
||||
* a decorated placeholder that said nothing about this install. Leading with
|
||||
* the artwork of what has actually been imported makes the empty state a
|
||||
* library view instead, and falls back to the examples only when there is
|
||||
* genuinely nothing to show.
|
||||
*/
|
||||
export function Welcome({
|
||||
onPick,
|
||||
recent,
|
||||
}: {
|
||||
onPick: (q: string) => void;
|
||||
recent: Job[];
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const seen = new Set<string>();
|
||||
const imported = recent
|
||||
.filter((j) => j.state === "imported" && j.cover)
|
||||
.filter((j) => {
|
||||
const k = `${j.artist}|${j.album ?? j.title}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="mb-8 max-w-[52ch] pt-6">
|
||||
<h1 className="text-hero">Find anything.</h1>
|
||||
<p className="mt-2.5 text-body text-dim">
|
||||
Search a song, preview it, and press Get. It arrives in your library correctly
|
||||
tagged — right artist, right album, right track number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{imported.length > 0 ? (
|
||||
<section aria-labelledby="recent-h">
|
||||
<h2 id="recent-h" className="mb-3 text-headline">
|
||||
Recently added
|
||||
</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-4">
|
||||
{imported.map((j, i) => (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(`${j.artist} ${j.title}`)}
|
||||
className="focus-ring group text-left"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={j.cover!}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="lift mb-2 aspect-square w-full rounded-panel border border-edge
|
||||
object-cover shadow-[var(--e1)]"
|
||||
/>
|
||||
<div className="text-callout font-medium clamp1">{j.title}</div>
|
||||
<div className="text-caption text-dim clamp1">{j.artist}</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="try-h">
|
||||
<h2 id="try-h" className="mb-3 flex items-center gap-2 text-headline">
|
||||
<Search className="h-4 w-4 text-mute" /> Try one of these
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLES.map((e, i) => (
|
||||
<motion.button
|
||||
key={e}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(e)}
|
||||
className="focus-ring rounded-pill border border-edge bg-[var(--surface)] px-3.5
|
||||
py-1.5 text-callout text-dim transition-colors hover:border-accent-line
|
||||
hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
{e}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user