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,32 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Error({ reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||
<div>
|
||||
<h1 className="text-display">Something broke</h1>
|
||||
<p className="mx-auto mt-2 max-w-[46ch] text-body text-dim">
|
||||
The view failed to render. Your requests are unaffected — they run on the server.
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="focus-ring rounded-control bg-accent px-4 py-2 text-callout
|
||||
font-semibold text-[var(--accent-ink)]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring rounded-control border border-edge-hi px-4 py-2
|
||||
text-callout font-semibold text-ink"
|
||||
>
|
||||
Back to search
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+179
-115
@@ -3,40 +3,67 @@
|
||||
/* ───────────────────────────────────────────────────────────────
|
||||
muse design system
|
||||
|
||||
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
||||
from whatever album is on screen; every tinted surface derives from them,
|
||||
so the page takes on the palette of the music instead of a fixed brand
|
||||
colour. JS writes those three numbers and nothing else.
|
||||
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
|
||||
|
||||
The previous system sampled the album artwork and repainted the whole
|
||||
interface from it. That is the inverse of how a native application behaves
|
||||
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
|
||||
only saturated thing on screen -- and it is most of why this read as a
|
||||
themed web page rather than an app.
|
||||
|
||||
So there are two colours now:
|
||||
|
||||
--accent fixed. Controls, selection, focus rings. Never sampled.
|
||||
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
|
||||
|
||||
That split also closes a real bug: the focus ring used to be drawn in the
|
||||
sampled colour, so its contrast against the page depended on which album
|
||||
you happened to be looking at.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@theme inline {
|
||||
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-line: var(--accent-line);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
|
||||
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
|
||||
--color-ink: var(--fg);
|
||||
--color-dim: var(--dim);
|
||||
--color-mute: var(--mute);
|
||||
--color-edge: var(--edge);
|
||||
--color-edge-hi: var(--edge-hi);
|
||||
--color-faint: var(--faint);
|
||||
|
||||
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
|
||||
for the sidebar and toolbar; previously `glass` was on everything, which
|
||||
is why nothing read as layered. */
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-3: var(--surface-3);
|
||||
|
||||
--color-ok: var(--ok);
|
||||
--color-err: var(--err);
|
||||
--color-warn: var(--warn);
|
||||
|
||||
--radius-glass: 14px;
|
||||
--radius-panel: 28px;
|
||||
--radius-control: 11px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 12px;
|
||||
--radius-panel: 18px;
|
||||
--radius-sheet: 22px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
||||
and settle. A single shared curve keeps unrelated components feeling like
|
||||
one physical system. */
|
||||
/* One shared curve, so unrelated components feel like one physical system. */
|
||||
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
--ease-spring: linear(
|
||||
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
||||
);
|
||||
|
||||
/* System faces on purpose. next/font self-hosts a webfont, which would make
|
||||
the Docker build reach out to Google, and `font-src 'self'` in the CSP
|
||||
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
|
||||
this is used from, which is the target anyway. */
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
@@ -45,9 +72,13 @@
|
||||
system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
|
||||
/* Type scale. The defining trait of Apple's typography is that tracking
|
||||
tightens as size grows — display text at default tracking looks loose and
|
||||
amateurish, and small text at display tracking looks cramped. */
|
||||
/* Type scale. Tracking tightens as size grows -- display text at default
|
||||
tracking looks loose, small text at display tracking looks cramped. */
|
||||
--text-hero: 2.75rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--text-hero--letter-spacing: -0.038em;
|
||||
--text-hero--font-weight: 700;
|
||||
|
||||
--text-display: 2.125rem;
|
||||
--text-display--line-height: 1.08;
|
||||
--text-display--letter-spacing: -0.033em;
|
||||
@@ -74,88 +105,113 @@
|
||||
--text-caption: 0.6875rem;
|
||||
--text-caption--line-height: 1.35;
|
||||
--text-caption--letter-spacing: 0.005em;
|
||||
|
||||
/* Named layout constants. These were arbitrary literals repeated across
|
||||
files: max-w-[940px] twice, gap-[7px] five times. */
|
||||
--spacing-row: 7px;
|
||||
--container-3xl: 1280px;
|
||||
--sidebar-w: 240px;
|
||||
--rail-w: 68px;
|
||||
--toolbar-h: 52px;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Default artwork tint, used until a cover is sampled. */
|
||||
--a-r: 120;
|
||||
--a-g: 140;
|
||||
--a-b: 255;
|
||||
|
||||
--bg: #08090d;
|
||||
--fg: #f2f4f9;
|
||||
--dim: #9aa3b6;
|
||||
--faint: rgb(255 255 255 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.055);
|
||||
--glass-hi: rgb(255 255 255 / 0.085);
|
||||
--edge: rgb(255 255 255 / 0.1);
|
||||
--edge-hi: rgb(255 255 255 / 0.17);
|
||||
--sheet: rgb(16 18 26 / 0.86);
|
||||
--header: rgb(8 9 13 / 0.62);
|
||||
--scrim: rgb(8 9 13 / 0.74);
|
||||
--scrim-2: rgb(8 9 13 / 0.4);
|
||||
--blur: saturate(180%) blur(24px);
|
||||
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
||||
--bg: #0a0b0f;
|
||||
--surface: #121319;
|
||||
--surface-2: #181a22;
|
||||
--surface-3: #20232c;
|
||||
|
||||
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
||||
dark background; the same amber on white fails contrast outright, which
|
||||
is exactly the text a reviewer most needs to read. */
|
||||
--ok: #3ddc84;
|
||||
--err: #ff6b6b;
|
||||
--warn: #ffc95c;
|
||||
--fg: #f4f6fb;
|
||||
--dim: #a0a8bb;
|
||||
--mute: #6d7488;
|
||||
|
||||
--faint: rgb(255 255 255 / 0.06);
|
||||
--edge: rgb(255 255 255 / 0.09);
|
||||
--edge-hi: rgb(255 255 255 / 0.16);
|
||||
|
||||
/* Vibrancy, for sidebar and toolbar only. */
|
||||
--material: rgb(18 19 25 / 0.72);
|
||||
--material-hi: rgb(24 26 34 / 0.86);
|
||||
--blur: saturate(180%) blur(20px);
|
||||
|
||||
--scrim: rgb(10 11 15 / 0.76);
|
||||
--scrim-2: rgb(10 11 15 / 0.35);
|
||||
|
||||
/* Fixed accent. Apple's system blue, which stays legible on both grounds
|
||||
and is independent of whatever is playing. */
|
||||
--accent: #0a84ff;
|
||||
--accent-soft: rgb(10 132 255 / 0.16);
|
||||
--accent-line: rgb(10 132 255 / 0.45);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--ok: #30d158;
|
||||
--err: #ff453a;
|
||||
--warn: #ff9f0a;
|
||||
|
||||
/* The specular edge. A real frosted panel catches light along its top rim;
|
||||
without this, blur alone reads as a flat grey rectangle. This one line is
|
||||
most of the difference between "translucent" and "material". */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
||||
without this, blur alone reads as a flat grey rectangle. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||
|
||||
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
||||
element, and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
||||
element and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f5f8;
|
||||
--fg: #0e1116;
|
||||
--dim: #5a6274;
|
||||
--faint: rgb(10 14 25 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.7);
|
||||
--glass-hi: rgb(255 255 255 / 0.88);
|
||||
--edge: rgb(10 14 25 / 0.09);
|
||||
--edge-hi: rgb(10 14 25 / 0.16);
|
||||
--sheet: rgb(255 255 255 / 0.95);
|
||||
--header: rgb(244 245 248 / 0.72);
|
||||
--scrim: rgb(255 255 255 / 0.78);
|
||||
--scrim-2: rgb(255 255 255 / 0.5);
|
||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
||||
/* On light material the specular rim is near-white and the shadows have
|
||||
to lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
|
||||
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f3f6;
|
||||
--surface-3: #e9ebf0;
|
||||
|
||||
--fg: #0d1117;
|
||||
--dim: #596273;
|
||||
--mute: #858da0;
|
||||
|
||||
--faint: rgb(10 14 25 / 0.05);
|
||||
--edge: rgb(10 14 25 / 0.1);
|
||||
--edge-hi: rgb(10 14 25 / 0.18);
|
||||
|
||||
--material: rgb(246 247 249 / 0.72);
|
||||
--material-hi: rgb(255 255 255 / 0.88);
|
||||
|
||||
--scrim: rgb(255 255 255 / 0.8);
|
||||
--scrim-2: rgb(255 255 255 / 0.45);
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-soft: rgb(0 113 227 / 0.12);
|
||||
--accent-line: rgb(0 113 227 / 0.4);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
|
||||
still reads as the same status when the theme flips. */
|
||||
--ok: #0a7d43;
|
||||
--err: #c62828;
|
||||
--warn: #8a5a00;
|
||||
|
||||
/* On light material the specular rim is near-white and the shadows have to
|
||||
lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
@@ -167,8 +223,8 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Headings take the display face and its tighter tracking automatically,
|
||||
so a component never has to remember to ask for it. */
|
||||
/* Headings take the display face and its tighter tracking automatically, so
|
||||
a component never has to remember to ask for it. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@@ -189,43 +245,48 @@
|
||||
color: inherit;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--edge);
|
||||
/* Vibrancy, for chrome that sits over scrolling content. */
|
||||
@utility material {
|
||||
background: var(--material);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec);
|
||||
}
|
||||
|
||||
@utility glass-hi {
|
||||
background: var(--glass-hi);
|
||||
border: 1px solid var(--edge-hi);
|
||||
@utility material-hi {
|
||||
background: var(--material-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi);
|
||||
}
|
||||
|
||||
/* An opaque content surface. The default for cards, rows and panels. */
|
||||
@utility card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
box-shadow: var(--spec), var(--e1);
|
||||
}
|
||||
|
||||
@utility sheet {
|
||||
background: var(--sheet);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi), var(--e3);
|
||||
}
|
||||
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
|
||||
hover feedback is barely perceptible frame to frame and obvious in
|
||||
aggregate. Anything larger reads as a web page, not an interface. */
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
|
||||
feedback is barely perceptible frame to frame and obvious in aggregate.
|
||||
Anything larger reads as a web page, not an interface. */
|
||||
@utility lift {
|
||||
transition:
|
||||
transform 0.28s var(--ease-glass),
|
||||
box-shadow 0.28s var(--ease-glass),
|
||||
border-color 0.28s var(--ease-glass),
|
||||
background 0.28s var(--ease-glass);
|
||||
transform 0.28s var(--ease-out),
|
||||
box-shadow 0.28s var(--ease-out),
|
||||
border-color 0.28s var(--ease-out),
|
||||
background 0.28s var(--ease-out);
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--spec-hi), var(--e2);
|
||||
@@ -237,11 +298,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus only — a mouse click should never draw a ring. */
|
||||
@utility ring {
|
||||
/* ONE focus treatment for the whole app. There used to be two that looked
|
||||
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
|
||||
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
|
||||
@utility focus-ring {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,12 +329,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Very light noise over the gradients — large flat blurs band visibly on
|
||||
8-bit panels without it. */
|
||||
@utility grain {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
@@ -278,14 +336,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the
|
||||
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine —
|
||||
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
|
||||
is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine -- CSP
|
||||
governs markup, not the CSSOM -- which is why Framer Motion works. */
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
|
||||
.shimmer {
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
var(--glass) 30%,
|
||||
var(--glass-hi) 50%,
|
||||
var(--glass) 70%
|
||||
var(--surface-2) 30%,
|
||||
var(--surface-3) 50%,
|
||||
var(--surface-2) 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Framer's animations are JS-driven and ignore this block entirely, which is
|
||||
why every component also consults useReducedMotion(). This covers the CSS
|
||||
half: shimmer, spinners, and every utility transition above. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -6,19 +6,26 @@ export const metadata: Metadata = {
|
||||
description: "Request a song, get it in your library, correctly tagged.",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#08090d",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
/**
|
||||
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
||||
* but it makes the Docker build reach out to Google at build time, and
|
||||
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||
* which is the look being aimed at anyway.
|
||||
*
|
||||
* themeColor is a pair rather than a single hex: it paints the browser's own
|
||||
* chrome on mobile, and a hardcoded dark value left a black status bar sitting
|
||||
* above a white page for anyone using the light theme.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f6f7f9" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0f" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Without this, a bad path drops the user on Next's unstyled white error page,
|
||||
* which does not look like it belongs to anything.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||
<div>
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.08em] text-mute">404</p>
|
||||
<h1 className="mt-2 text-display">That page isn’t here</h1>
|
||||
<p className="mx-auto mt-2 max-w-[42ch] text-body text-dim">
|
||||
The link may be stale, or the view may have been renamed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring mt-6 inline-flex rounded-control bg-accent px-4 py-2
|
||||
text-callout font-semibold text-[var(--accent-ink)]"
|
||||
>
|
||||
Back to search
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+99
-277
@@ -2,13 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
||||
import { ArrowLeft, SearchX } from "lucide-react";
|
||||
|
||||
import { api, type Me, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNav, type Tab } from "@/lib/nav";
|
||||
import { panel, useMotionOK } from "@/lib/motion";
|
||||
|
||||
import { Aurora } from "@/components/aurora";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { AlbumView } from "@/components/album-view";
|
||||
@@ -18,32 +18,18 @@ import { Requests, type JobsSnapshot } from "@/components/requests";
|
||||
import { Review } from "@/components/review";
|
||||
import { DiscoverPanel } from "@/components/discover";
|
||||
import { Settings } from "@/components/settings";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { Welcome } from "@/components/welcome";
|
||||
import { PlayerProvider } from "@/components/player";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
type Tab =
|
||||
| "search"
|
||||
| "discover"
|
||||
| "jobs"
|
||||
| "review"
|
||||
| "bulk"
|
||||
| "settings"
|
||||
| "admin";
|
||||
type View =
|
||||
| { k: "welcome" }
|
||||
| { k: "results"; q: string }
|
||||
| { k: "album"; id: number }
|
||||
| { k: "artist"; id: number };
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
import { Sidebar } from "@/components/shell/sidebar";
|
||||
import { TabBar } from "@/components/shell/tabbar";
|
||||
import { Toolbar } from "@/components/shell/toolbar";
|
||||
import { StatusBanner } from "@/components/shell/status-banner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
@@ -55,36 +41,32 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
|
||||
function useBreakpoint() {
|
||||
const [w, setW] = React.useState(1280);
|
||||
React.useEffect(() => {
|
||||
const on = () => setW(window.innerWidth);
|
||||
on();
|
||||
window.addEventListener("resize", on);
|
||||
return () => window.removeEventListener("resize", on);
|
||||
}, []);
|
||||
return { rail: w >= 768 && w < 1024 };
|
||||
}
|
||||
|
||||
function Muse() {
|
||||
const [tab, setTab] = React.useState<Tab>("search");
|
||||
const [view, setView] = React.useState<View>({ k: "welcome" });
|
||||
const [history, setHistory] = React.useState<View[]>([]);
|
||||
const [nav, go] = useNav();
|
||||
const { rail } = useBreakpoint();
|
||||
const ok = useMotionOK();
|
||||
|
||||
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [me, setMe] = React.useState<Me | null>(null);
|
||||
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
||||
|
||||
const go = React.useCallback((next: View) => {
|
||||
setView((cur) => {
|
||||
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
||||
return next;
|
||||
});
|
||||
setTab("search");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const back = () => {
|
||||
setHistory((h) => {
|
||||
const prev = h[h.length - 1];
|
||||
if (prev) setView(prev);
|
||||
return h.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const doSearch = React.useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) return;
|
||||
go({ k: "results", q });
|
||||
go({ tab: "search", q });
|
||||
setResults(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -99,8 +81,24 @@ function Muse() {
|
||||
[go],
|
||||
);
|
||||
|
||||
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
||||
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
||||
// Re-runs the query when the URL carries one, so a shared link and a browser
|
||||
// Back both restore an actual result set rather than an empty screen.
|
||||
const lastQ = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (nav.tab !== "search" || nav.view || !nav.q) return;
|
||||
if (lastQ.current === nav.q) return;
|
||||
lastQ.current = nav.q;
|
||||
void doSearch(nav.q);
|
||||
}, [nav.tab, nav.view, nav.q, doSearch]);
|
||||
|
||||
const openAlbum = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
const openArtist = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -129,191 +127,64 @@ function Muse() {
|
||||
(snap?.counts.searching ?? 0) +
|
||||
(snap?.counts.downloading ?? 0);
|
||||
const held = snap?.counts.review ?? 0;
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
||||
// is noise on a single-user install, which is most of them.
|
||||
const quotaLeft =
|
||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
||||
|
||||
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
|
||||
// Review appears exactly when something is waiting on you; an always-present
|
||||
// tab that is empty nine days in ten is chrome.
|
||||
const visible = (t: Tab) =>
|
||||
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
|
||||
|
||||
const onBack = nav.view
|
||||
? () => go({ tab: "search", q: nav.q })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Aurora />
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
|
||||
|
||||
<header
|
||||
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
||||
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[940px] px-4">
|
||||
<div className="mb-3.5 flex items-center gap-3">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 12, scale: 1.06 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
||||
border border-accent-line bg-accent-soft text-accent"
|
||||
>
|
||||
<Music2 className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-title leading-none">muse</h1>
|
||||
<p className="mt-0.5 text-caption text-dim">
|
||||
search · request · it appears in Navidrome
|
||||
</p>
|
||||
</div>
|
||||
{me?.user && (
|
||||
<div className="flex items-center gap-2 text-callout text-dim">
|
||||
{quotaLeft !== null && (
|
||||
<span
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className={cn(
|
||||
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
|
||||
quotaLeft === 0
|
||||
? "border-err/50 text-err"
|
||||
: quotaLeft <= 5
|
||||
? "border-warn/50 text-warn"
|
||||
: "border-edge text-dim",
|
||||
)}
|
||||
>
|
||||
{quotaLeft} left
|
||||
</span>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full border border-edge"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden font-medium text-ink sm:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
{me.role && me.role !== "user" && (
|
||||
<span
|
||||
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
|
||||
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
|
||||
>
|
||||
{me.role}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="ring rounded-md px-1 transition-colors hover:text-ink"
|
||||
>
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Toolbar me={me} quotaLeft={quotaLeft}>
|
||||
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
</Toolbar>
|
||||
<StatusBanner />
|
||||
|
||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
|
||||
{/* A sliding pill rather than an underline: the moving surface is
|
||||
the selection itself, so the eye tracks one object across the
|
||||
row instead of watching two things fade. layoutId does the
|
||||
interpolation, so it stays correct when tabs appear or vanish
|
||||
with a role change. */}
|
||||
{/* The mask is the scroll affordance: on a narrow screen the last
|
||||
tab fades out at the edge instead of being clipped flat, which
|
||||
is the only cue that there is more to the right. */}
|
||||
<nav
|
||||
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
|
||||
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
|
||||
sm:[mask-image:none]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["search", "Search", 0],
|
||||
["discover", "Discover", 0],
|
||||
["jobs", "Requests", active],
|
||||
// Only exists when it is actionable. An always-present tab
|
||||
// that is empty nine days in ten is chrome; this one appears
|
||||
// exactly when something is waiting on you.
|
||||
...(held > 0 || tab === "review"
|
||||
? ([["review", "Review", held]] as [Tab, string, number][])
|
||||
: []),
|
||||
["bulk", "Import", 0],
|
||||
["settings", "Settings", 0],
|
||||
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
|
||||
] as [Tab, string, number][]
|
||||
).map(([k, label, badge]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
aria-current={tab === k ? "page" : undefined}
|
||||
className={cn(
|
||||
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
|
||||
"text-callout font-medium transition-colors duration-200",
|
||||
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{tab === k && (
|
||||
<motion.span
|
||||
layoutId="tab-pill"
|
||||
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
|
||||
transition={{ type: "spring", stiffness: 460, damping: 38 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
{label}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
|
||||
k === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
||||
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
||||
the incoming panel until the outgoing one's exit animation reports
|
||||
finished, which makes navigation depend on an animation completing.
|
||||
Anything that stalls the frame loop — a backgrounded tab, a browser
|
||||
that throttles rAF — then leaves the UI stuck on the old panel with
|
||||
no way forward. A cosmetic transition should never gate navigation. */}
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{tab === "search" && (
|
||||
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
|
||||
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
|
||||
{nav.tab === "search" && (
|
||||
<>
|
||||
{history.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={back}
|
||||
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
||||
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
||||
>
|
||||
{onBack && (
|
||||
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</motion.button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||
{nav.view === "album" && nav.id && (
|
||||
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{nav.view === "artist" && nav.id && (
|
||||
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
|
||||
{view.k === "results" &&
|
||||
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
|
||||
|
||||
{!nav.view &&
|
||||
nav.q &&
|
||||
(results === null ? (
|
||||
<Skeletons />
|
||||
<SkeletonRows n={6} />
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">
|
||||
No matches. Try adding the artist name.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No matches"
|
||||
body="Try adding the artist name, or fewer words."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
{results.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
@@ -324,71 +195,22 @@ function Muse() {
|
||||
onQueued={refresh}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{view.k === "album" && (
|
||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{tab === "review" && <Review onDecided={refresh} />}
|
||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
</motion.div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
||||
className="px-5 py-14 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -7, 0] }}
|
||||
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
||||
border border-accent-line bg-accent-soft text-accent"
|
||||
>
|
||||
<Disc3 className="h-7 w-7" />
|
||||
</motion.div>
|
||||
<h2 className="mb-2 text-display">Find anything</h2>
|
||||
<p className="mx-auto max-w-[46ch] text-body text-dim">
|
||||
Search a song and press{" "}
|
||||
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
|
||||
Enter
|
||||
</kbd>
|
||||
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
||||
your library, tagged.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
{EXAMPLES.map((e, i) => (
|
||||
<motion.button
|
||||
key={e}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.05 }}
|
||||
whileHover={{ y: -2 }}
|
||||
onClick={() => onPick(e)}
|
||||
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
|
||||
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
{e}
|
||||
</motion.button>
|
||||
))}
|
||||
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{nav.tab === "review" && <Review onDecided={refresh} />}
|
||||
{nav.tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
</motion.div>
|
||||
</main>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user