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
e1b7b69261
47 files changed
+2939
-805
No files matched your search
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user