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,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "muse",
|
||||||
|
"runtimeExecutable": "bash",
|
||||||
|
"runtimeArgs": ["dev.sh"],
|
||||||
|
"port": 5041
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,3 +5,8 @@ config/
|
|||||||
.venv/
|
.venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
# Local diagnostics: contain the live instance's Soulseek username,
|
||||||
|
# server paths and peer names. Not for a public repo.
|
||||||
|
FINDINGS.md
|
||||||
|
SERVER_AUDIT.md
|
||||||
@@ -148,8 +148,9 @@ automatically. Every job shows what it *resolved to*, so a bad auto-pick is
|
|||||||
visible in the list rather than quietly landing the wrong song. Lines that
|
visible in the list rather than quietly landing the wrong song. Lines that
|
||||||
match nothing are reported back as skipped instead of failing the batch.
|
match nothing are reported back as skipped instead of failing the batch.
|
||||||
|
|
||||||
Failed jobs get a **Retry** button. Most Soulseek failures are the peer going
|
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
|
||||||
offline, not a bad request, so retrying later usually just works.
|
a request that loses its peer falls through to the next candidate on its own.
|
||||||
|
See "When a peer says no" below.
|
||||||
|
|
||||||
### Everything goes through a bounded queue
|
### Everything goes through a bounded queue
|
||||||
|
|
||||||
@@ -179,10 +180,83 @@ is needed to catch `Come As You Are (Live)`, but substring matching would also
|
|||||||
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
|
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
|
||||||
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
|
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
|
||||||
|
|
||||||
**Picking the file (Soulseek).** Format preference first, then whether the peer
|
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
|
||||||
can actually serve you (free upload slot, short queue), then filename
|
first — a free upload slot and a short queue — then format preference, then
|
||||||
plausibility, then bitrate. A marginally better file behind 200 queued users
|
filename plausibility, then bitrate. A marginally better file behind 200 queued
|
||||||
loses to a good-enough one available now.
|
users loses to a good-enough one available now.
|
||||||
|
|
||||||
|
That order is deliberate and was once the other way round, which was a bug
|
||||||
|
worth naming: format outranked availability, so a FLAC behind a five-thousand
|
||||||
|
deep queue always beat an MP3 that was free to download immediately. Whether
|
||||||
|
the peer can serve you is the single most predictive signal for whether the
|
||||||
|
job ever finishes, so nothing outranks it.
|
||||||
|
|
||||||
|
## Searching for a name the file doesn't use
|
||||||
|
|
||||||
|
Two things go wrong between "what Deezer calls this track" and "what a stranger
|
||||||
|
named the file", and both used to end the request.
|
||||||
|
|
||||||
|
**The Soulseek server filters some artist names.** This is measurable: on a
|
||||||
|
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
|
||||||
|
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
|
||||||
|
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
|
||||||
|
Ocean. Sending one query of `artist title` — which is all muse used to do —
|
||||||
|
means every request for a filtered artist fails reporting "searched 0 files",
|
||||||
|
indistinguishable from a track nobody shares.
|
||||||
|
|
||||||
|
So muse tries a **ladder** of queries, loosest last, and stops at the first
|
||||||
|
rung that yields enough candidates:
|
||||||
|
|
||||||
|
```
|
||||||
|
Michael Jackson Billie Jean 0 responses
|
||||||
|
Billie Jean 251 responses, 1083 files
|
||||||
|
```
|
||||||
|
|
||||||
|
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
|
||||||
|
a `feat.` credit, a leading article, everything after a `/` in a split title,
|
||||||
|
and finally the artist itself. Since most requests are answered by the first
|
||||||
|
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
|
||||||
|
caps how far it will go.
|
||||||
|
|
||||||
|
Dropping the artist from the *query* does not mean ignoring it. The artist is
|
||||||
|
then matched against the whole **folder path**, which is where a well-organised
|
||||||
|
share puts it anyway:
|
||||||
|
|
||||||
|
```
|
||||||
|
anjya music/Michael Jackson/1982 - Thriller [flac]/06 - Billie Jean.flac
|
||||||
|
^^^^^^^^^^^^^^^^ artist found here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Names differ in punctuation and accents.** Normalisation folds accents away
|
||||||
|
before comparing, so `Beyoncé` matches a file called `Beyonce`, `Björk` matches
|
||||||
|
`Bjork`, and `Don't Stop` matches `Dont Stop`. Apostrophes are removed rather
|
||||||
|
than turned into a space; `&` still separates, so `R&B` stays two tokens
|
||||||
|
instead of collapsing into the unrelated word `rb`. Track numbers and release
|
||||||
|
tags (`02 - `, `[320kbps]`) are stripped before the comparison, and a
|
||||||
|
reordered filename (`Creep - Radiohead`) scores the same as `Radiohead - Creep`.
|
||||||
|
|
||||||
|
The title has to stand on its own: a matching artist cannot carry a filename
|
||||||
|
whose title is wrong. Without that floor, a strong artist match let a
|
||||||
|
*different song by the right artist* through.
|
||||||
|
|
||||||
|
## When a peer says no
|
||||||
|
|
||||||
|
`Transfer rejected: File not shared.` is the commonest failure on Soulseek —
|
||||||
|
the peer's share index is stale and the file it advertised is gone. It is a
|
||||||
|
condition of one *file*, not of the request, and the right response is to take
|
||||||
|
the next candidate.
|
||||||
|
|
||||||
|
muse keeps the whole ranked list (`MUSE_CANDIDATES`, default 8) on the job row
|
||||||
|
rather than only the winner, so when a transfer is rejected, times out, errors,
|
||||||
|
or slskd cannot open a connection to the peer at all, the next candidate is
|
||||||
|
enqueued instead of the request failing. The list is ordered to put a different
|
||||||
|
peer next, so one offline user cannot burn three attempts in a row. Only when
|
||||||
|
the list is exhausted — or `MUSE_MAX_ATTEMPTS` is reached — does the job fail,
|
||||||
|
and it then reports every peer it tried and what each one said.
|
||||||
|
|
||||||
|
A download that sits with no bytes for `MUSE_STALL_MINUTES` is abandoned the
|
||||||
|
same way, as is one slskd has forgotten about. Both used to leave the job in
|
||||||
|
`downloading` forever, with a cheerful pulsing dot and no log line.
|
||||||
|
|
||||||
## Review: when muse isn't sure
|
## Review: when muse isn't sure
|
||||||
|
|
||||||
@@ -470,7 +544,14 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
|
|||||||
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
|
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
|
||||||
| `MUSE_DB` | `/config/muse.db` | job history |
|
| `MUSE_DB` | `/config/muse.db` | job history |
|
||||||
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
||||||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
|
| `MUSE_MIN_MATCH` | `0.45` | filename-to-track similarity floor |
|
||||||
|
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
|
||||||
|
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
|
||||||
|
| `MUSE_MAX_ATTEMPTS` | `5` | peers tried before a request is given up on |
|
||||||
|
| `MUSE_STALL_MINUTES` | `20` | a transfer moving no bytes for this long is abandoned |
|
||||||
|
| `MUSE_MAX_QUERIES` | `4` | rungs of the search ladder to try |
|
||||||
|
| `MUSE_MIN_CANDIDATES` | `3` | below this, escalate to the next query |
|
||||||
|
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
|
||||||
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
|
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
|
||||||
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
|
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
|
||||||
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
||||||
|
|||||||
@@ -26,13 +26,16 @@ file on disk, two names — the same trick the *arrs use.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import collections
|
||||||
import difflib
|
import difflib
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import unicodedata
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -42,7 +45,7 @@ from urllib.parse import urlencode
|
|||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException, Query, Request
|
from fastapi import FastAPI, HTTPException, Query, Request
|
||||||
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
|
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
|
||||||
RedirectResponse)
|
RedirectResponse, Response)
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -68,6 +71,35 @@ FORMATS = [f.strip() for f in os.getenv("MUSE_FORMATS", "flac,mp3").split(",") i
|
|||||||
# so this compares against a normalised form, not the raw string.
|
# so this compares against a normalised form, not the raw string.
|
||||||
MIN_MATCH = float(os.getenv("MUSE_MIN_MATCH", "0.45"))
|
MIN_MATCH = float(os.getenv("MUSE_MIN_MATCH", "0.45"))
|
||||||
|
|
||||||
|
# The title alone must clear this before the artist is even consulted. Without
|
||||||
|
# a floor of its own, a file by the right artist scored well enough on the
|
||||||
|
# combined ratio to be fetched no matter which of their songs it actually was.
|
||||||
|
TITLE_FLOOR = float(os.getenv("MUSE_TITLE_FLOOR", "0.5"))
|
||||||
|
|
||||||
|
# How many ranked candidates to keep per job. When a transfer fails, the next
|
||||||
|
# one is tried instead of failing the request -- "File not shared." is a
|
||||||
|
# per-file condition, so candidate two usually just works.
|
||||||
|
CANDIDATES = int(os.getenv("MUSE_CANDIDATES", "8"))
|
||||||
|
MAX_ATTEMPTS = int(os.getenv("MUSE_MAX_ATTEMPTS", "5"))
|
||||||
|
|
||||||
|
# A download that has moved no bytes for this long is not going to. The peer
|
||||||
|
# queued it and vanished, or slskd lost the transfer; either way the job used
|
||||||
|
# to sit in `downloading` forever behind a cheerful pulsing dot.
|
||||||
|
STALL_MINUTES = float(os.getenv("MUSE_STALL_MINUTES", "20"))
|
||||||
|
|
||||||
|
# Query variants to try before giving up. Each costs a real Soulseek search,
|
||||||
|
# so this escalates only when a search underdelivers -- see search_queries().
|
||||||
|
MAX_QUERIES = int(os.getenv("MUSE_MAX_QUERIES", "4"))
|
||||||
|
MIN_CANDIDATES = int(os.getenv("MUSE_MIN_CANDIDATES", "3"))
|
||||||
|
|
||||||
|
# Issuing a search is a POST that occasionally blips. Nine production failures
|
||||||
|
# were one slskd hiccup during a bulk import, every queued job dying inside the
|
||||||
|
# same second, each one permanently.
|
||||||
|
SEARCH_RETRIES = int(os.getenv("MUSE_SEARCH_RETRIES", "3"))
|
||||||
|
|
||||||
|
# Soulseek results trickle in: what you have at t=5s is much worse than t=15s.
|
||||||
|
SEARCH_POLLS = int(os.getenv("MUSE_SEARCH_POLLS", "20"))
|
||||||
|
|
||||||
# How many Soulseek searches may run at once.
|
# How many Soulseek searches may run at once.
|
||||||
#
|
#
|
||||||
# This is a hard cap, not a suggestion. Each search takes 10-40s of polling
|
# This is a hard cap, not a suggestion. Each search takes 10-40s of polling
|
||||||
@@ -177,7 +209,8 @@ GOOGLE_API = "https://openidconnect.googleapis.com/v1/userinfo"
|
|||||||
|
|
||||||
# Paths reachable without a session. Deliberately tiny: /healthz reports a bare
|
# Paths reachable without a session. Deliberately tiny: /healthz reports a bare
|
||||||
# ok so the container healthcheck works without leaking configuration.
|
# ok so the container healthcheck works without leaking configuration.
|
||||||
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"}
|
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout",
|
||||||
|
"/login.css"}
|
||||||
|
|
||||||
ROLES = ("admin", "user", "guest")
|
ROLES = ("admin", "user", "guest")
|
||||||
|
|
||||||
@@ -334,6 +367,28 @@ SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|||||||
NOISE = re.compile(r"[^a-z0-9]+")
|
NOISE = re.compile(r"[^a-z0-9]+")
|
||||||
YEAR = re.compile(r"^\d{4}$")
|
YEAR = re.compile(r"^\d{4}$")
|
||||||
|
|
||||||
|
# Apostrophes vanish rather than becoming a separator, so "Don't" normalises
|
||||||
|
# onto a filename's "Dont". Everything else non-alphanumeric becomes a space,
|
||||||
|
# so "R&B" stays two tokens instead of collapsing to the unrelated word "rb".
|
||||||
|
QUOTES = re.compile("['\u2018\u2019\u02bc\u0060\"\u201c\u201d]")
|
||||||
|
|
||||||
|
# Letters NFKD leaves alone because they are atomic rather than base + accent.
|
||||||
|
FOLD = str.maketrans({"\u00f8": "o", "\u00e6": "ae", "\u0153": "oe",
|
||||||
|
"\u00f0": "d", "\u00fe": "th", "\u0142": "l",
|
||||||
|
"\u00df": "ss", "\u0111": "d", "\u0131": "i"})
|
||||||
|
|
||||||
|
# A leading track number: "02 - ", "02.", "A1 ". Anchored and bounded so it
|
||||||
|
# cannot eat a title that merely opens with a digit ("1979", "99 Problems").
|
||||||
|
TRACKNO = re.compile(r"^\s*(?:[a-d]?\d{1,3})\s*(?:[-._)\]]\s*)+", re.I)
|
||||||
|
|
||||||
|
# Technical release tags. Deliberately NOT "live", "remix" or "acoustic":
|
||||||
|
# confidence() reads those to decide whether this is the recording that was
|
||||||
|
# asked for, and stripping them here would blind it.
|
||||||
|
JUNK = re.compile(
|
||||||
|
r"[\[({]\s*(?:\d{2,4}\s*k(?:bps)?|v\d|web|cd\s*\d*|vinyl|flac|mp3|"
|
||||||
|
r"lossless|hi-?res|\d{2,3}\s*bit|\d{2}\s*khz)[^\])}]*[\])}]"
|
||||||
|
r"|\b(?:\d{3}\s*kbps|web-?dl|cdrip|vbr|cbr)\b", re.I)
|
||||||
|
|
||||||
# Most filesystems cap a path component at 255 bytes. Track titles can be far
|
# Most filesystems cap a path component at 255 bytes. Track titles can be far
|
||||||
# longer than that once a stranger has appended their own notes, and the
|
# longer than that once a stranger has appended their own notes, and the
|
||||||
# resulting ENAMETOOLONG would fail the import after the download had finished.
|
# resulting ENAMETOOLONG would fail the import after the download had finished.
|
||||||
@@ -365,7 +420,71 @@ def safe_year(year) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def norm(s: str) -> str:
|
def norm(s: str) -> str:
|
||||||
return NOISE.sub(" ", s.lower()).strip()
|
"""Lowercase, fold accents away, and reduce to alphanumeric tokens.
|
||||||
|
|
||||||
|
The accent fold is load-bearing. Deezer returns "Beyonc\u00e9", "Bj\u00f6rk" and
|
||||||
|
"Sigur R\u00f3s"; the strangers who named the files on Soulseek wrote
|
||||||
|
"Beyonce", "Bjork", "Sigur Ros". Without it the accented character is not
|
||||||
|
in [a-z0-9] and became a *space*, so "beyonce" was compared against
|
||||||
|
"beyonc " -- a containment miss and a mediocre ratio, on a track that was
|
||||||
|
sitting right there in the results.
|
||||||
|
"""
|
||||||
|
s = unicodedata.normalize("NFKD", str(s).lower().translate(FOLD))
|
||||||
|
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||||
|
return NOISE.sub(" ", QUOTES.sub("", s)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def bare_name(remote_path: str) -> str:
|
||||||
|
"""A remote filename with extension, track number and release tags gone.
|
||||||
|
|
||||||
|
All of that is decoration a peer added that says nothing about which
|
||||||
|
recording the file holds. Leaving it in drags the similarity ratio down on
|
||||||
|
files that are in fact exactly right.
|
||||||
|
"""
|
||||||
|
base = remote_path.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
if "." in base:
|
||||||
|
base = base.rsplit(".", 1)[0]
|
||||||
|
return JUNK.sub(" ", TRACKNO.sub("", base)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def similarity(want: str, got: str) -> float:
|
||||||
|
"""Best of a sequence ratio and a token-coverage ratio, 0..1.
|
||||||
|
|
||||||
|
difflib alone is order-sensitive: "creep radiohead" against "radiohead
|
||||||
|
creep" scores far below what a human would call a match, and Soulseek
|
||||||
|
filenames put the fields in whatever order the ripper felt like. The token
|
||||||
|
measure is order-free, so taking the better of the two absorbs reordering
|
||||||
|
and interleaved junk without having to loosen MIN_MATCH.
|
||||||
|
"""
|
||||||
|
if not want or not got:
|
||||||
|
return 0.0
|
||||||
|
seq = difflib.SequenceMatcher(None, want, got).ratio()
|
||||||
|
a, b = set(want.split()), set(got.split())
|
||||||
|
return max(seq, len(a & b) / len(a) if a else 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def match_ratio(remote_path: str, artist: str, title: str) -> float:
|
||||||
|
"""How well a remote path matches the track asked for, 0..1.
|
||||||
|
|
||||||
|
Title and artist are measured separately and the title has to stand on its
|
||||||
|
own. A single ratio over the blob "artist title" let a strong artist match
|
||||||
|
carry a weak title -- the same failure the Deezer ranking keeps a dedicated
|
||||||
|
term for, where `Oasis - Live Forever` once resolved to `Cloudburst`
|
||||||
|
because the artist agreed and nothing else had to.
|
||||||
|
|
||||||
|
The artist is looked for in the whole path rather than the filename: a file
|
||||||
|
at `Radiohead/Pablo Honey/02 Creep.flac` names its artist in the folder,
|
||||||
|
which is the commonest well-organised case there is.
|
||||||
|
"""
|
||||||
|
stem = norm(bare_name(remote_path))
|
||||||
|
path = norm(remote_path.replace("\\", "/"))
|
||||||
|
nt, na = norm(title), norm(artist)
|
||||||
|
|
||||||
|
t = 1.0 if (nt and nt in stem) else similarity(nt, stem)
|
||||||
|
if t < TITLE_FLOOR:
|
||||||
|
return 0.0 # wrong song; a matching artist must not rescue it
|
||||||
|
a = 1.0 if (na and na in path) else similarity(na, path)
|
||||||
|
return 0.75 * t + 0.25 * a
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── storage ───────────────────────────
|
# ─────────────────────────── storage ───────────────────────────
|
||||||
@@ -382,7 +501,7 @@ def init_db():
|
|||||||
c.execute("""
|
c.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
state TEXT NOT NULL, -- searching|queued|downloading|imported|failed
|
state TEXT NOT NULL, -- queued|searching|downloading|review|imported|failed
|
||||||
detail TEXT,
|
detail TEXT,
|
||||||
artist TEXT, title TEXT, album TEXT,
|
artist TEXT, title TEXT, album TEXT,
|
||||||
year TEXT, track INTEGER,
|
year TEXT, track INTEGER,
|
||||||
@@ -426,7 +545,15 @@ def init_db():
|
|||||||
("duration", "INTEGER"),
|
("duration", "INTEGER"),
|
||||||
("confidence", "REAL"),
|
("confidence", "REAL"),
|
||||||
("match_why", "TEXT"),
|
("match_why", "TEXT"),
|
||||||
("reviewed_by", "TEXT")):
|
("reviewed_by", "TEXT"),
|
||||||
|
# The candidate ladder. Keeping the runners-up on the
|
||||||
|
# row is what lets a rejected transfer fall through to
|
||||||
|
# the next peer minutes later, in a different task,
|
||||||
|
# after the search result set is long gone.
|
||||||
|
("candidates", "TEXT"),
|
||||||
|
("tried", "TEXT"),
|
||||||
|
("attempts", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("started_at", "TEXT")):
|
||||||
if col not in cols:
|
if col not in cols:
|
||||||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||||||
ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
|
ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
|
||||||
@@ -521,15 +648,14 @@ def confidence(filename: str, artist: str, title: str,
|
|||||||
Returns the score and the human-readable reasons behind it, because a
|
Returns the score and the human-readable reasons behind it, because a
|
||||||
number alone gives the reviewer nothing to act on.
|
number alone gives the reviewer nothing to act on.
|
||||||
"""
|
"""
|
||||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
stem = norm(bare_name(filename))
|
||||||
stem = norm(base.rsplit(".", 1)[0])
|
|
||||||
path = norm(filename.replace("\\", "/"))
|
path = norm(filename.replace("\\", "/"))
|
||||||
na, nt = norm(artist), norm(title)
|
na, nt = norm(artist), norm(title)
|
||||||
why: list[str] = []
|
why: list[str] = []
|
||||||
|
|
||||||
# Title is the load-bearing signal: a wrong title is a wrong song, whereas
|
# Title is the load-bearing signal: a wrong title is a wrong song, whereas
|
||||||
# a missing artist is routine in files organised into per-artist folders.
|
# a missing artist is routine in files organised into per-artist folders.
|
||||||
t_ratio = difflib.SequenceMatcher(None, nt, stem).ratio()
|
t_ratio = similarity(nt, stem)
|
||||||
if nt and nt in stem:
|
if nt and nt in stem:
|
||||||
t_score, t_ratio = 1.0, max(t_ratio, 1.0)
|
t_score, t_ratio = 1.0, max(t_ratio, 1.0)
|
||||||
why.append("title matches")
|
why.append("title matches")
|
||||||
@@ -587,104 +713,310 @@ AUTO_CONFIDENCE = float(os.getenv("MUSE_AUTO_CONFIDENCE", "0.95"))
|
|||||||
REVIEW_CONFIDENCE = float(os.getenv("MUSE_REVIEW_CONFIDENCE", "0.70"))
|
REVIEW_CONFIDENCE = float(os.getenv("MUSE_REVIEW_CONFIDENCE", "0.70"))
|
||||||
|
|
||||||
|
|
||||||
def score(candidate: dict, peer: dict, want: str) -> float | None:
|
def score(candidate: dict, peer: dict, artist: str, title: str) -> float | None:
|
||||||
"""Rank one remote file. None = reject.
|
"""Rank one remote file. None = reject.
|
||||||
|
|
||||||
Ordering intent: correct format first, then actually-gettable (a peer with
|
Ordering intent: actually-gettable first, then correct format, then
|
||||||
a free slot and a short queue beats a marginally better file behind 200
|
filename plausibility, then bitrate.
|
||||||
people), then filename plausibility, then bitrate.
|
|
||||||
|
Availability leads deliberately. It used to sit *below* format -- 100
|
||||||
|
points per format rank against 60 for a free upload slot -- which meant a
|
||||||
|
FLAC behind two hundred queued people always beat an MP3 available right
|
||||||
|
now. Whether the peer can serve you is the single most predictive signal
|
||||||
|
for whether the job ever finishes, so it outranks the preference for a
|
||||||
|
better file. Format is the tiebreak within an availability tier.
|
||||||
"""
|
"""
|
||||||
ext = ext_of(candidate["filename"])
|
ext = ext_of(candidate["filename"])
|
||||||
if ext not in FORMATS:
|
if ext not in FORMATS:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
base = candidate["filename"].replace("\\", "/").rsplit("/", 1)[-1]
|
ratio = match_ratio(candidate["filename"], artist, title)
|
||||||
ratio = difflib.SequenceMatcher(None, norm(base), want).ratio()
|
|
||||||
# A short title inside a long scene filename scores badly on raw ratio, so
|
|
||||||
# give credit for simple containment too.
|
|
||||||
if want in norm(base):
|
|
||||||
ratio = max(ratio, 0.9)
|
|
||||||
if ratio < MIN_MATCH:
|
if ratio < MIN_MATCH:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
s = 0.0
|
s = 0.0
|
||||||
s += (len(FORMATS) - FORMATS.index(ext)) * 100 # format preference
|
s += 220 if peer.get("hasFreeUploadSlot") else 0
|
||||||
s += 60 if peer.get("hasFreeUploadSlot") else 0
|
# Unsaturated: the old `max(0, 40 - queueLength)` scored a 40-deep queue
|
||||||
s += max(0, 40 - peer.get("queueLength", 0)) # short queues
|
# and a 5000-deep one identically, which is the difference between waiting
|
||||||
s += min(peer.get("uploadSpeed", 0) / 50_000, 20) # capped speed bonus
|
# a minute and never being served at all.
|
||||||
s += ratio * 50
|
s += 120 / (1 + peer.get("queueLength", 0) / 8)
|
||||||
|
s += min(peer.get("uploadSpeed", 0) / 50_000, 20)
|
||||||
|
s += (len(FORMATS) - FORMATS.index(ext)) * 60 # format preference
|
||||||
|
s += ratio * 90
|
||||||
s += min(candidate.get("bitRate") or 0, 320) / 32 # 0-10
|
s += min(candidate.get("bitRate") or 0, 320) / 32 # 0-10
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
async def pick_and_enqueue(job_id: str, artist: str, title: str,
|
PAREN = re.compile(r"[\[(\{][^\])}]*[\])}]")
|
||||||
album: str | None = None):
|
FEAT = re.compile(r"\b(?:feat|ft|featuring)\b\.?.*$", re.I)
|
||||||
"""Search slskd for one track and queue the best candidate."""
|
ARTICLE = re.compile(r"^(?:the|a|an|los|las|les)\s+", re.I)
|
||||||
want = norm(f"{artist} {title}")
|
|
||||||
|
|
||||||
|
class SearchUnavailable(Exception):
|
||||||
|
"""slskd could not accept a search. Transient, not a bad request."""
|
||||||
|
|
||||||
|
|
||||||
|
def search_queries(artist: str, title: str) -> list[str]:
|
||||||
|
"""Query variants to try, loosest last.
|
||||||
|
|
||||||
|
The Soulseek server filters searches containing certain artist names. That
|
||||||
|
is measured, not theorised: on this network "Michael Jackson Billie Jean"
|
||||||
|
and bare "Michael Jackson" both return zero responses, while "Billie Jean"
|
||||||
|
returns 1083 files. Sending only `artist title` -- all this used to do --
|
||||||
|
means every request for a filtered artist dies reporting "searched 0
|
||||||
|
files", and that is exactly the roster of names in the failed jobs:
|
||||||
|
Michael Jackson, The Weeknd, Kanye West, Frank Ocean.
|
||||||
|
|
||||||
|
So the ladder ends at the title alone, and match_ratio() then recognises
|
||||||
|
the artist from the folder path instead of from the query. The middle
|
||||||
|
rungs handle the ordinary case where Deezer's metadata is simply more
|
||||||
|
decorated than the filename a stranger typed.
|
||||||
|
"""
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def add(*parts: str) -> None:
|
||||||
|
q = " ".join(p for p in parts if p).strip()
|
||||||
|
# Dedupe on the normalised form, so "Creep (Remastered)" and "Creep"
|
||||||
|
# are recognised as one query once the punctuation is gone.
|
||||||
|
k = norm(q)
|
||||||
|
if q and k and k not in seen:
|
||||||
|
seen.add(k)
|
||||||
|
out.append(q)
|
||||||
|
|
||||||
|
plain_t = PAREN.sub(" ", title).strip() or title
|
||||||
|
lean_t = FEAT.sub(" ", plain_t).strip() or plain_t
|
||||||
|
head_t = lean_t.split("/")[0].strip() or lean_t # "A / B" -> "A"
|
||||||
|
lean_a = ARTICLE.sub("", FEAT.sub(" ", artist).strip()).strip()
|
||||||
|
|
||||||
|
add(artist, title) # what was sent before, and still best when it works
|
||||||
|
add(artist, lean_t) # decoration and featuring credits gone
|
||||||
|
add(lean_a, head_t) # leading article gone, split title
|
||||||
|
add(head_t) # title alone -- this is the rung that beats the filter
|
||||||
|
add(lean_t)
|
||||||
|
return out[:MAX_QUERIES]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_search(sc: httpx.AsyncClient, text: str) -> dict:
|
||||||
|
"""Issue one Soulseek search and poll it to completion.
|
||||||
|
|
||||||
|
The search is deleted only once it has actually finished. Deleting one
|
||||||
|
still InProgress makes slskd throw an optimistic-concurrency error inside
|
||||||
|
its own SearchService and lose the record; the old code deleted
|
||||||
|
unconditionally after its 40s budget, so every slow search corrupted a row.
|
||||||
|
"""
|
||||||
search_id = str(uuid.uuid4())
|
search_id = str(uuid.uuid4())
|
||||||
async with slskd_client() as sc:
|
for attempt in range(SEARCH_RETRIES):
|
||||||
try:
|
try:
|
||||||
r = await sc.post("/api/v0/searches",
|
r = await sc.post("/api/v0/searches",
|
||||||
json={"id": search_id, "searchText": f"{artist} {title}"})
|
json={"id": search_id, "searchText": text})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
set_state(job_id, "failed", f"slskd search failed: {e}")
|
if attempt == SEARCH_RETRIES - 1:
|
||||||
return
|
raise SearchUnavailable(errstr(e)) from e
|
||||||
|
await asyncio.sleep(2 * (attempt + 1))
|
||||||
|
|
||||||
# Poll until slskd calls it done, or we run out of patience. Soulseek
|
results: dict = {}
|
||||||
# searches trickle in; results at t=5s are much worse than at t=15s.
|
finished = False
|
||||||
results = {}
|
for _ in range(SEARCH_POLLS):
|
||||||
for _ in range(20):
|
await asyncio.sleep(2)
|
||||||
await asyncio.sleep(2)
|
try:
|
||||||
try:
|
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
||||||
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
params={"includeResponses": "true"})
|
||||||
params={"includeResponses": "true"})
|
rr.raise_for_status()
|
||||||
rr.raise_for_status()
|
results = rr.json()
|
||||||
results = rr.json()
|
except Exception as e:
|
||||||
except Exception:
|
# Swallowed silently before, which made a broken slskd look
|
||||||
continue
|
# identical to a track nobody happens to share.
|
||||||
if str(results.get("state", "")).startswith("Completed"):
|
log.debug("poll %r: %s", text, errstr(e))
|
||||||
break
|
continue
|
||||||
|
if str(results.get("state", "")).startswith("Completed"):
|
||||||
|
finished = True
|
||||||
|
break
|
||||||
|
|
||||||
best, best_score = None, -1.0
|
if finished:
|
||||||
for peer in results.get("responses", []):
|
|
||||||
for f in peer.get("files", []):
|
|
||||||
if f.get("isLocked"):
|
|
||||||
continue
|
|
||||||
sc_ = score(f, peer, want)
|
|
||||||
if sc_ is not None and sc_ > best_score:
|
|
||||||
best, best_score = (peer, f), sc_
|
|
||||||
|
|
||||||
# Tidy up so slskd's search history doesn't grow without bound.
|
|
||||||
try:
|
try:
|
||||||
await sc.delete(f"/api/v0/searches/{search_id}")
|
await sc.delete(f"/api/v0/searches/{search_id}")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not best:
|
|
||||||
set_state(job_id, "failed",
|
|
||||||
f"no {'/'.join(FORMATS)} candidate matched (searched {results.get('fileCount', 0)} files)")
|
|
||||||
return
|
|
||||||
|
|
||||||
peer, f = best
|
|
||||||
try:
|
|
||||||
er = await sc.post(f"/api/v0/transfers/downloads/{peer['username']}",
|
|
||||||
json=[{"filename": f["filename"], "size": f["size"]}])
|
|
||||||
er.raise_for_status()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
set_state(job_id, "failed", f"enqueue failed: {e}")
|
log.debug("cleanup %r: %s", text, errstr(e))
|
||||||
return
|
else:
|
||||||
|
log.info("search %r unfinished after %ds, leaving it for slskd to close",
|
||||||
|
text, SEARCH_POLLS * 2)
|
||||||
|
return results
|
||||||
|
|
||||||
# Confidence is recorded now, while the candidate list is still in
|
|
||||||
# hand, but it gates the IMPORT rather than the download. Holding the
|
def spread_by_peer(ranked: list[tuple[float, dict, dict]]) -> list[dict]:
|
||||||
# transfer instead would mean the peer is often gone by the time
|
"""Order candidates so consecutive attempts land on different peers.
|
||||||
# someone reviews it; this way the file is already on disk, approving
|
|
||||||
# is instant, and rejecting is a delete.
|
Straight score order routinely stacks three files from one well-stocked
|
||||||
conf, why = confidence(f["filename"], artist, title, album)
|
user at the top; if that user is offline all three attempts burn on the
|
||||||
|
same dead connection. Peers arrive best-first, so round-robining them keeps
|
||||||
|
the strongest candidate first and still guarantees variety behind it.
|
||||||
|
"""
|
||||||
|
by_peer: dict[str, list[dict]] = {}
|
||||||
|
for s, peer, f in ranked:
|
||||||
|
by_peer.setdefault(peer["username"], []).append({
|
||||||
|
"username": peer["username"],
|
||||||
|
"filename": f["filename"],
|
||||||
|
"size": f.get("size") or 0,
|
||||||
|
"score": round(s, 1),
|
||||||
|
})
|
||||||
|
queues = list(by_peer.values())
|
||||||
|
out: list[dict] = []
|
||||||
|
while queues:
|
||||||
|
for q in list(queues):
|
||||||
|
out.append(q.pop(0))
|
||||||
|
if not q:
|
||||||
|
queues.remove(q)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def no_candidate_detail(seen: int, stats: "collections.Counter",
|
||||||
|
queries: list[str]) -> str:
|
||||||
|
"""Say which filter emptied the results, not merely that they are empty.
|
||||||
|
|
||||||
|
One message used to cover four unrelated situations, and 28 of 29 real
|
||||||
|
occurrences turned out to be the search itself coming back empty -- which
|
||||||
|
is now stated outright instead of being reported as a matching failure.
|
||||||
|
"""
|
||||||
|
if seen == 0:
|
||||||
|
tried = ", ".join(repr(q) for q in queries)
|
||||||
|
word = "y" if len(queries) == 1 else "ies"
|
||||||
|
return f"Soulseek returned nothing for {len(queries)} quer{word}: {tried}"
|
||||||
|
bits = []
|
||||||
|
if stats["format"]:
|
||||||
|
bits.append(f"{stats['format']} not {'/'.join(FORMATS)}")
|
||||||
|
if stats["weak"]:
|
||||||
|
bits.append(f"{stats['weak']} did not match the title")
|
||||||
|
if stats["locked"]:
|
||||||
|
bits.append(f"{stats['locked']} locked")
|
||||||
|
return f"{seen} files found, none usable: " + ", ".join(bits)
|
||||||
|
|
||||||
|
|
||||||
|
def exhausted_detail(tried: list[dict], attempts: int) -> str:
|
||||||
|
"""Summarise the whole ladder instead of overwriting it with the last error."""
|
||||||
|
if not tried:
|
||||||
|
return "no candidate could be enqueued"
|
||||||
|
reasons = collections.Counter(t["reason"].split(":")[0] for t in tried)
|
||||||
|
plural = "" if attempts == 1 else "s"
|
||||||
|
joined = ", ".join(f"{n}x {r}" for r, n in reasons.most_common(3))
|
||||||
|
return f"tried {attempts} peer{plural}: {joined}"
|
||||||
|
|
||||||
|
|
||||||
|
async def try_next_candidate(job_id: str, artist: str, title: str,
|
||||||
|
album: str | None = None) -> bool:
|
||||||
|
"""Enqueue the next-best candidate. False once the ladder is exhausted.
|
||||||
|
|
||||||
|
This is the difference between a request that survives one bad peer and one
|
||||||
|
that does not. "Transfer rejected: File not shared." is a *per-file*
|
||||||
|
condition -- the peer's share index is stale -- so candidate two almost
|
||||||
|
always works, and slskd's 500 on an unreachable peer is the same shape.
|
||||||
|
"""
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute(
|
||||||
|
"SELECT candidates, tried, attempts FROM jobs WHERE id=?",
|
||||||
|
(job_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
cands = json.loads(row["candidates"] or "[]")
|
||||||
|
tried = json.loads(row["tried"] or "[]")
|
||||||
|
attempts = row["attempts"] or 0
|
||||||
|
|
||||||
|
while cands and attempts < MAX_ATTEMPTS:
|
||||||
|
cand = cands.pop(0)
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
async with slskd_client() as sc:
|
||||||
|
er = await sc.post(
|
||||||
|
f"/api/v0/transfers/downloads/{cand['username']}",
|
||||||
|
json=[{"filename": cand["filename"],
|
||||||
|
"size": cand["size"]}])
|
||||||
|
er.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
tried.append({"username": cand["username"],
|
||||||
|
"filename": cand["filename"],
|
||||||
|
"reason": f"could not reach peer: {errstr(e)}"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
conf, why = confidence(cand["filename"], artist, title, album)
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
|
||||||
|
(json.dumps(cands), json.dumps(tried), attempts, job_id))
|
||||||
|
extra = f", attempt {attempts}" if attempts > 1 else ""
|
||||||
set_state(job_id, "downloading",
|
set_state(job_id, "downloading",
|
||||||
f"from {peer['username']} (score {best_score:.0f})",
|
f"from {cand['username']} (score {cand['score']:.0f}{extra})",
|
||||||
username=peer["username"], remote_file=f["filename"],
|
username=cand["username"], remote_file=cand["filename"],
|
||||||
size=f["size"], confidence=conf, match_why="; ".join(why))
|
size=cand["size"], confidence=conf, match_why="; ".join(why),
|
||||||
|
started_at=datetime.now(timezone.utc).isoformat())
|
||||||
|
return True
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=? WHERE id=?",
|
||||||
|
(json.dumps(cands), json.dumps(tried), attempts, job_id))
|
||||||
|
set_state(job_id, "failed", exhausted_detail(tried, attempts))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def pick_and_enqueue(job_id: str, artist: str, title: str,
|
||||||
|
album: str | None = None):
|
||||||
|
"""Search Soulseek for one track and queue the best candidate.
|
||||||
|
|
||||||
|
Builds a ranked candidate *list* rather than a single winner, so a peer
|
||||||
|
that rejects, times out or cannot be reached costs one attempt instead of
|
||||||
|
the entire request.
|
||||||
|
"""
|
||||||
|
queries = search_queries(artist, title)
|
||||||
|
pool: dict[tuple[str, str], tuple[float, dict, dict]] = {}
|
||||||
|
seen = 0
|
||||||
|
stats: "collections.Counter" = collections.Counter()
|
||||||
|
|
||||||
|
async with slskd_client() as sc:
|
||||||
|
for i, q in enumerate(queries):
|
||||||
|
try:
|
||||||
|
results = await run_search(sc, q)
|
||||||
|
except SearchUnavailable as e:
|
||||||
|
set_state(job_id, "failed",
|
||||||
|
f"slskd would not accept a search: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for peer in results.get("responses", []):
|
||||||
|
for f in peer.get("files", []):
|
||||||
|
seen += 1
|
||||||
|
if f.get("isLocked"):
|
||||||
|
stats["locked"] += 1
|
||||||
|
continue
|
||||||
|
if ext_of(f["filename"]) not in FORMATS:
|
||||||
|
stats["format"] += 1
|
||||||
|
continue
|
||||||
|
sc_ = score(f, peer, artist, title)
|
||||||
|
if sc_ is None:
|
||||||
|
stats["weak"] += 1
|
||||||
|
continue
|
||||||
|
key = (peer["username"], f["filename"])
|
||||||
|
if key not in pool or pool[key][0] < sc_:
|
||||||
|
pool[key] = (sc_, peer, f)
|
||||||
|
|
||||||
|
log.info("job %s q%d/%d %r -> %d responses, %d files, %d candidates",
|
||||||
|
job_id[:8], i + 1, len(queries), q,
|
||||||
|
len(results.get("responses", [])),
|
||||||
|
results.get("fileCount", 0), len(pool))
|
||||||
|
# Escalate only on underdelivery: the common case stays one search,
|
||||||
|
# which is what keeps a 50-track paste civil to slskd.
|
||||||
|
if len(pool) >= MIN_CANDIDATES:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not pool:
|
||||||
|
set_state(job_id, "failed", no_candidate_detail(seen, stats, queries))
|
||||||
|
return
|
||||||
|
|
||||||
|
ranked = sorted(pool.values(), key=lambda r: -r[0])
|
||||||
|
cands = spread_by_peer(ranked)[:CANDIDATES]
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE jobs SET candidates=?, tried=?, attempts=0 WHERE id=?",
|
||||||
|
(json.dumps(cands), "[]", job_id))
|
||||||
|
await try_next_candidate(job_id, artist, title, album)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── email ───────────────────────────
|
# ─────────────────────────── email ───────────────────────────
|
||||||
@@ -867,14 +1199,23 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
def find_downloaded(remote_file: str) -> Path | None:
|
def downloaded_index() -> dict[str, Path]:
|
||||||
"""Locate the completed file. slskd nests it under the remote's parent
|
"""Every completed file, by basename.
|
||||||
directory name, so match on basename rather than guessing the path."""
|
|
||||||
base = remote_file.replace("\\", "/").rsplit("/", 1)[-1]
|
slskd nests a download under the remote's parent directory name, so the
|
||||||
for p in DOWNLOAD_DIR.rglob("*"):
|
basename is the only reliable key. Built once per poll cycle and shared by
|
||||||
if p.is_file() and p.name == base:
|
every job in it: the previous version walked the whole tree separately for
|
||||||
return p
|
each in-flight job, every ten seconds, which on a large downloads
|
||||||
return None
|
directory is a lot of stat() calls to answer the same question.
|
||||||
|
"""
|
||||||
|
out: dict[str, Path] = {}
|
||||||
|
try:
|
||||||
|
for p in DOWNLOAD_DIR.rglob("*"):
|
||||||
|
if p.is_file():
|
||||||
|
out.setdefault(p.name, p)
|
||||||
|
except OSError as e:
|
||||||
|
log.warning("cannot walk %s: %s", DOWNLOAD_DIR, errstr(e))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── search queue ───────────────────────────
|
# ─────────────────────────── search queue ───────────────────────────
|
||||||
@@ -903,8 +1244,64 @@ async def search_worker(n: int):
|
|||||||
work_q.task_done()
|
work_q.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_index(payload: list) -> dict[str, dict]:
|
||||||
|
"""Flatten slskd's user -> directories -> files nesting.
|
||||||
|
|
||||||
|
Keeps the whole file object, not just its state. `exception` is where the
|
||||||
|
literal "Transfer rejected: File not shared." lives, and discarding it was
|
||||||
|
why a failed job could only ever say "transfer Completed, Rejected" with no
|
||||||
|
reason a person could act on.
|
||||||
|
"""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for user in payload:
|
||||||
|
for d in user.get("directories", []):
|
||||||
|
for f in d.get("files", []):
|
||||||
|
out[f.get("filename", "")] = f
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def stalled_for(job: sqlite3.Row) -> float:
|
||||||
|
"""Minutes since this transfer last had anything to show for itself."""
|
||||||
|
started = job["started_at"] if "started_at" in job.keys() else None
|
||||||
|
if not started:
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
t = datetime.fromisoformat(started)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
if t.tzinfo is None:
|
||||||
|
t = t.replace(tzinfo=timezone.utc)
|
||||||
|
return (datetime.now(timezone.utc) - t).total_seconds() / 60.0
|
||||||
|
|
||||||
|
|
||||||
|
async def abandon(job: sqlite3.Row, reason: str) -> None:
|
||||||
|
"""Give up on the current peer and fall through to the next candidate."""
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT tried FROM jobs WHERE id=?",
|
||||||
|
(job["id"],)).fetchone()
|
||||||
|
tried = json.loads((row["tried"] if row else None) or "[]")
|
||||||
|
tried.append({"username": job["username"], "filename": job["remote_file"],
|
||||||
|
"reason": reason})
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE jobs SET tried=? WHERE id=?",
|
||||||
|
(json.dumps(tried), job["id"]))
|
||||||
|
log.info("job %s giving up on %s: %s", job["id"][:8], job["username"], reason)
|
||||||
|
await try_next_candidate(job["id"], job["artist"], job["title"], job["album"])
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_transfer(username: str, filename: str) -> None:
|
||||||
|
"""Ask slskd to drop a transfer we have stopped waiting for."""
|
||||||
|
try:
|
||||||
|
async with slskd_client(timeout=PROBE_TIMEOUT) as sc:
|
||||||
|
await sc.delete(f"/api/v0/transfers/downloads/{username}",
|
||||||
|
params={"filename": filename})
|
||||||
|
except Exception as e:
|
||||||
|
log.debug("cancel %s/%s: %s", username, filename, errstr(e))
|
||||||
|
|
||||||
|
|
||||||
async def import_worker():
|
async def import_worker():
|
||||||
"""Poll slskd for in-flight downloads and import the finished ones."""
|
"""Poll slskd for in-flight downloads and import the finished ones."""
|
||||||
|
missing: collections.Counter = collections.Counter()
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
try:
|
try:
|
||||||
@@ -912,28 +1309,36 @@ async def import_worker():
|
|||||||
jobs = c.execute(
|
jobs = c.execute(
|
||||||
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
|
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
|
||||||
if not jobs:
|
if not jobs:
|
||||||
|
missing.clear()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with slskd_client() as sc:
|
async with slskd_client() as sc:
|
||||||
r = await sc.get("/api/v0/transfers/downloads")
|
r = await sc.get("/api/v0/transfers/downloads")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
payload = r.json()
|
index = transfer_index(r.json())
|
||||||
|
|
||||||
# Flatten slskd's user -> directories -> files nesting.
|
# One directory walk per cycle, shared by every job. This used to be
|
||||||
states: dict[str, str] = {}
|
# a full recursive rglob per stuck job, every ten seconds, forever.
|
||||||
for user in payload:
|
done_files: dict[str, Path] | None = None
|
||||||
for d in user.get("directories", []):
|
|
||||||
for f in d.get("files", []):
|
|
||||||
states[f.get("filename", "")] = str(f.get("state", ""))
|
|
||||||
|
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
st = states.get(job["remote_file"], "")
|
t = index.get(job["remote_file"])
|
||||||
|
st = str((t or {}).get("state", ""))
|
||||||
|
|
||||||
if "Completed, Succeeded" in st:
|
if "Completed, Succeeded" in st:
|
||||||
src = find_downloaded(job["remote_file"])
|
if done_files is None:
|
||||||
|
done_files = downloaded_index()
|
||||||
|
src = done_files.get(
|
||||||
|
job["remote_file"].replace("\\", "/").rsplit("/", 1)[-1])
|
||||||
if not src:
|
if not src:
|
||||||
continue # still being moved out of incomplete
|
# Normally just still being moved out of incomplete, but
|
||||||
# Record the real size, not the size the peer advertised —
|
# unbounded before: a misconfigured DOWNLOAD_DIR meant
|
||||||
# storage quotas are computed from this column.
|
# polling until the heat death of the process.
|
||||||
|
if stalled_for(job) > STALL_MINUTES:
|
||||||
|
set_state(job["id"], "failed",
|
||||||
|
"downloaded but the file never appeared in "
|
||||||
|
f"{DOWNLOAD_DIR} -- check MUSE_DOWNLOAD_DIR")
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
actual = src.stat().st_size
|
actual = src.stat().st_size
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -941,11 +1346,11 @@ async def import_worker():
|
|||||||
|
|
||||||
conf = job["confidence"]
|
conf = job["confidence"]
|
||||||
if conf is not None and conf < AUTO_CONFIDENCE:
|
if conf is not None and conf < AUTO_CONFIDENCE:
|
||||||
# Hold it. The file stays in downloads, untagged and
|
band = ("almost certainly wrong"
|
||||||
# unlinked, until someone approves or rejects it.
|
if conf < REVIEW_CONFIDENCE else "worth a look")
|
||||||
set_state(job["id"], "review",
|
set_state(job["id"], "review",
|
||||||
f"{conf:.0%} confident — {job['match_why'] or ''}",
|
f"{conf:.0%} confident, {band} -- "
|
||||||
size=actual)
|
f"{job['match_why'] or ''}", size=actual)
|
||||||
await notify_review(job)
|
await notify_review(job)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -953,10 +1358,36 @@ async def import_worker():
|
|||||||
set_state(job["id"], "imported", str(dest),
|
set_state(job["id"], "imported", str(dest),
|
||||||
imported_path=str(dest), size=actual)
|
imported_path=str(dest), size=actual)
|
||||||
await notify_imported(job, dest)
|
await notify_imported(job, dest)
|
||||||
|
|
||||||
elif "Completed" in st and "Succeeded" not in st:
|
elif "Completed" in st and "Succeeded" not in st:
|
||||||
set_state(job["id"], "failed", f"transfer {st}")
|
# Every terminal non-success used to end the request here.
|
||||||
|
# "File not shared." is per-file and per-peer, so the only
|
||||||
|
# sane response is to try the next candidate.
|
||||||
|
why = (t or {}).get("exception") or st
|
||||||
|
await abandon(job, str(why)[:120])
|
||||||
|
|
||||||
|
elif t is None:
|
||||||
|
# Absent from slskd entirely: it restarted, or the transfer
|
||||||
|
# was cleared. Tolerate a couple of polls in case it is a
|
||||||
|
# race, then stop waiting for something nobody is doing.
|
||||||
|
missing[job["id"]] += 1
|
||||||
|
if missing[job["id"]] >= 3:
|
||||||
|
del missing[job["id"]]
|
||||||
|
await abandon(job, "slskd forgot the transfer")
|
||||||
|
|
||||||
|
else:
|
||||||
|
missing.pop(job["id"], None)
|
||||||
|
# Queued behind a wall, or connected and sending nothing.
|
||||||
|
# There was no clock on this at all, which is how a job sat
|
||||||
|
# in `downloading` for days behind a cheerful pulsing dot.
|
||||||
|
if not (t or {}).get("bytesTransferred") \
|
||||||
|
and stalled_for(job) > STALL_MINUTES:
|
||||||
|
place = (t or {}).get("placeInQueue")
|
||||||
|
where = f" at #{place} in the queue" if place else ""
|
||||||
|
await cancel_transfer(job["username"], job["remote_file"])
|
||||||
|
await abandon(job, f"no bytes in {STALL_MINUTES:.0f}m{where}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("worker: %s", e)
|
log.warning("import worker: %s", errstr(e))
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────── api ───────────────────────────
|
# ─────────────────────────── api ───────────────────────────
|
||||||
@@ -1094,20 +1525,91 @@ async def healthz():
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
# The sign-in page is the first screen anyone sees, and for a while it was the
|
||||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>muse</title>
|
# only one built out of hardcoded hex with no relation to the rest of the app.
|
||||||
</head><body style="margin:0;min-height:100vh;display:grid;place-items:center;
|
# It cannot use the Next bundle -- /_next/* sits behind the session on purpose,
|
||||||
background:#08090d;color:#f2f4f9;font:15px/1.5 -apple-system,BlinkMacSystemFont,
|
# so an unauthenticated visitor never receives it -- so it gets one small
|
||||||
ui-sans-serif,system-ui,sans-serif">
|
# stylesheet of its own, served from a public path. `style-src 'self'` in the
|
||||||
<div style="text-align:center;padding:32px">
|
# CSP permits exactly that; an inline <style> block would be refused.
|
||||||
<div style="font-size:26px;font-weight:700;letter-spacing:-.5px">muse</div>
|
LOGIN_CSS = """
|
||||||
<div style="color:#9aa3b6;font-size:13px;margin:6px 0 26px">Sign in to continue</div>
|
:root{
|
||||||
{buttons}</div></body></html>"""
|
--bg:#0a0b0f; --surface:#121319; --surface-2:#181a22;
|
||||||
|
--fg:#f4f6fb; --dim:#a0a8bb; --mute:#6d7488;
|
||||||
|
--edge:rgb(255 255 255/.09); --edge-hi:rgb(255 255 255/.16);
|
||||||
|
--accent:#0a84ff; --accent-ink:#fff;
|
||||||
|
--e2:0 2px 6px rgb(0 0 0/.34), 0 12px 32px rgb(0 0 0/.26);
|
||||||
|
color-scheme:light dark;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:light){
|
||||||
|
:root{
|
||||||
|
--bg:#f6f7f9; --surface:#fff; --surface-2:#f2f3f6;
|
||||||
|
--fg:#0d1117; --dim:#596273; --mute:#858da0;
|
||||||
|
--edge:rgb(10 14 25/.1); --edge-hi:rgb(10 14 25/.18);
|
||||||
|
--accent:#0071e3;
|
||||||
|
--e2:0 2px 6px rgb(20 30 60/.07), 0 12px 32px rgb(20 30 60/.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{
|
||||||
|
margin:0; min-height:100dvh; display:grid; place-items:center; padding:24px;
|
||||||
|
background:var(--bg); color:var(--fg);
|
||||||
|
font:15px/1.55 -apple-system,BlinkMacSystemFont,"SF Pro Text",ui-sans-serif,
|
||||||
|
system-ui,"Segoe UI",Roboto,sans-serif;
|
||||||
|
letter-spacing:-.008em; -webkit-font-smoothing:antialiased;
|
||||||
|
}
|
||||||
|
.card{
|
||||||
|
width:100%; max-width:352px; padding:32px 28px; text-align:center;
|
||||||
|
background:var(--surface); border:1px solid var(--edge);
|
||||||
|
border-radius:18px; box-shadow:var(--e2);
|
||||||
|
}
|
||||||
|
.mark{
|
||||||
|
width:44px; height:44px; margin:0 auto 18px; display:grid; place-items:center;
|
||||||
|
background:var(--accent); border-radius:12px; color:var(--accent-ink);
|
||||||
|
}
|
||||||
|
h1{
|
||||||
|
margin:0; font-size:23px; font-weight:700; letter-spacing:-.024em; line-height:1.18;
|
||||||
|
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display",ui-sans-serif,system-ui,sans-serif;
|
||||||
|
}
|
||||||
|
p.sub{margin:6px 0 24px; font-size:13px; color:var(--dim)}
|
||||||
|
a.btn{
|
||||||
|
display:flex; align-items:center; justify-content:center; gap:9px;
|
||||||
|
margin:8px 0; padding:11px 18px; border-radius:10px; text-decoration:none;
|
||||||
|
background:var(--surface-2); border:1px solid var(--edge-hi);
|
||||||
|
color:var(--fg); font-size:13px; font-weight:600;
|
||||||
|
transition:border-color .15s, background .15s, transform .1s;
|
||||||
|
}
|
||||||
|
a.btn:hover{border-color:var(--accent); background:var(--bg)}
|
||||||
|
a.btn:active{transform:scale(.985)}
|
||||||
|
a.btn:focus-visible{outline:2px solid var(--accent); outline-offset:2px}
|
||||||
|
p.foot{margin:22px 0 0; font-size:11px; color:var(--mute)}
|
||||||
|
"""
|
||||||
|
|
||||||
_BTN = ('<a href="/auth/login?provider={p}" style="display:block;margin:10px auto;'
|
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||||
'padding:12px 26px;min-width:230px;border-radius:12px;text-decoration:none;'
|
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||||
'background:rgb(255 255 255/.06);border:1px solid rgb(255 255 255/.14);'
|
<meta name=theme-color media="(prefers-color-scheme: light)" content="#f6f7f9">
|
||||||
'color:#f2f4f9;font-weight:600">Continue with {label}</a>')
|
<meta name=theme-color media="(prefers-color-scheme: dark)" content="#0a0b0f">
|
||||||
|
<title>Sign in · muse</title>
|
||||||
|
<link rel=stylesheet href="/login.css"></head>
|
||||||
|
<body><main class=card>
|
||||||
|
<div class=mark><svg width=22 height=22 viewBox="0 0 24 24" fill=none
|
||||||
|
stroke=currentColor stroke-width=2 stroke-linecap=round stroke-linejoin=round
|
||||||
|
aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/>
|
||||||
|
<circle cx="18" cy="16" r="3"/></svg></div>
|
||||||
|
<h1>muse</h1>
|
||||||
|
<p class=sub>Request a song, get it in your library.</p>
|
||||||
|
{buttons}
|
||||||
|
<p class=foot>You’ll be sent to your provider to sign in.</p>
|
||||||
|
</main></body></html>"""
|
||||||
|
|
||||||
|
_BTN = ('<a class=btn href="/auth/login?provider={p}">Continue with {label}</a>')
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login.css")
|
||||||
|
async def login_css():
|
||||||
|
"""The sign-in page's stylesheet. Public, because it is needed by someone
|
||||||
|
who by definition has no session yet."""
|
||||||
|
return Response(LOGIN_CSS, media_type="text/css",
|
||||||
|
headers={"Cache-Control": "public, max-age=3600"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/login")
|
@app.get("/auth/login")
|
||||||
@@ -1844,7 +2346,8 @@ async def review_decide(job_id: str, body: ReviewBody, request: Request):
|
|||||||
if not job or (not admin and job["requested_by"] != me):
|
if not job or (not admin and job["requested_by"] != me):
|
||||||
raise HTTPException(404, "no such review item")
|
raise HTTPException(404, "no such review item")
|
||||||
|
|
||||||
src = find_downloaded(job["remote_file"] or "")
|
base = (job["remote_file"] or "").replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
src = downloaded_index().get(base)
|
||||||
if body.action == "reject":
|
if body.action == "reject":
|
||||||
if src:
|
if src:
|
||||||
try:
|
try:
|
||||||
@@ -1948,6 +2451,25 @@ async def recap(request: Request, days: int = Query(365, ge=7, le=3650)):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def job_dict(r: sqlite3.Row) -> dict:
|
||||||
|
"""A job as the UI wants it.
|
||||||
|
|
||||||
|
`candidates` is deliberately dropped: it is the untried remainder of the
|
||||||
|
ladder, several KB of peer names and paths per job, and the jobs list is
|
||||||
|
polled every four seconds. `tried` is sent parsed, because it is the answer
|
||||||
|
to "why did this fail" and the UI renders it as a list.
|
||||||
|
"""
|
||||||
|
d = dict(r)
|
||||||
|
d.pop("candidates", None)
|
||||||
|
try:
|
||||||
|
d["tried"] = json.loads(d.get("tried") or "[]")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
d["tried"] = []
|
||||||
|
d["attempts"] = d.get("attempts") or 0
|
||||||
|
d["remaining"] = max(0, MAX_ATTEMPTS - d["attempts"])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs")
|
@app.get("/api/jobs")
|
||||||
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
||||||
mine: bool = False):
|
mine: bool = False):
|
||||||
@@ -1969,7 +2491,7 @@ async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
|||||||
counts = {r["state"]: r["n"] for r in c.execute(
|
counts = {r["state"]: r["n"] for r in c.execute(
|
||||||
f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state",
|
f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state",
|
||||||
args).fetchall()}
|
args).fetchall()}
|
||||||
return {"jobs": [dict(r) for r in rows], "counts": counts,
|
return {"jobs": [job_dict(r) for r in rows], "counts": counts,
|
||||||
"pending": work_q.qsize(), "scope": "all" if everyone else "mine"}
|
"pending": work_q.qsize(), "scope": "all" if everyone else "mine"}
|
||||||
|
|
||||||
|
|
||||||
@@ -1980,7 +2502,7 @@ async def retry(job_id: str, request: Request):
|
|||||||
just works."""
|
just works."""
|
||||||
u = current_user(request)
|
u = current_user(request)
|
||||||
with db() as c:
|
with db() as c:
|
||||||
row = c.execute("SELECT id, requested_by FROM jobs WHERE id=?",
|
row = c.execute("SELECT id, state, requested_by FROM jobs WHERE id=?",
|
||||||
(job_id,)).fetchone()
|
(job_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "no such job")
|
raise HTTPException(404, "no such job")
|
||||||
@@ -1990,6 +2512,19 @@ async def retry(job_id: str, request: Request):
|
|||||||
if AUTH_MODE != "off" and u and u["role"] != "admin" \
|
if AUTH_MODE != "off" and u and u["role"] != "admin" \
|
||||||
and row["requested_by"] != u["username"]:
|
and row["requested_by"] != u["username"]:
|
||||||
raise HTTPException(404, "no such job")
|
raise HTTPException(404, "no such job")
|
||||||
|
# Only failed jobs. Requeueing one in `review` used to strand its
|
||||||
|
# downloaded file on disk with no row left pointing at it, and requeueing
|
||||||
|
# one already `imported` would re-download a track that is in the library.
|
||||||
|
if row["state"] != "failed":
|
||||||
|
raise HTTPException(409, f"only failed jobs can be retried "
|
||||||
|
f"(this one is {row['state']})")
|
||||||
|
# Clear the previous attempt, or the ladder resumes mid-way through a
|
||||||
|
# candidate list gathered for a search that has long since expired.
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE jobs SET username=NULL, remote_file=NULL, size=NULL,"
|
||||||
|
" confidence=NULL, match_why=NULL, candidates=NULL,"
|
||||||
|
" tried=NULL, attempts=0, started_at=NULL WHERE id=?",
|
||||||
|
(job_id,))
|
||||||
set_state(job_id, "queued", "")
|
set_state(job_id, "queued", "")
|
||||||
await work_q.put(job_id)
|
await work_q.put(job_id)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -2339,6 +2874,19 @@ async def health():
|
|||||||
r = await sc.get("/api/v0/application")
|
r = await sc.get("/api/v0/application")
|
||||||
detail["slskd"] = r.status_code
|
detail["slskd"] = r.status_code
|
||||||
ok = ok and r.status_code == 200
|
ok = ok and r.status_code == 200
|
||||||
|
# Reachable and logged out is the failure worth naming: every
|
||||||
|
# search returns nothing and every request fails, but the container
|
||||||
|
# is up and healthy so nothing else reports a problem. This used to
|
||||||
|
# be visible only on the admin panel, which meant an ordinary user
|
||||||
|
# watched their requests fail with no way to learn why.
|
||||||
|
if r.status_code == 200:
|
||||||
|
app_state = r.json()
|
||||||
|
srv = app_state.get("server") or {}
|
||||||
|
detail["logged_in"] = bool(srv.get("isLoggedIn"))
|
||||||
|
detail["shared_files"] = (
|
||||||
|
((app_state.get("user") or {}).get("statistics") or {})
|
||||||
|
.get("fileCount"))
|
||||||
|
ok = ok and detail["logged_in"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
detail["slskd"] = errstr(e)
|
detail["slskd"] = errstr(e)
|
||||||
ok = False
|
ok = False
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""The acquisition ladder: query variants, candidate fallback, stall handling.
|
||||||
|
|
||||||
|
This is the path that had no coverage at all, and it is where every production
|
||||||
|
failure lived. A fake slskd stands in for the network so the branches that only
|
||||||
|
happen on a bad day -- a peer that rejects, a transfer slskd forgets, a search
|
||||||
|
the server filters -- can be exercised deterministically.
|
||||||
|
|
||||||
|
Run standalone: python tests/test_acquisition.py
|
||||||
|
"""
|
||||||
|
import asyncio, json, os, sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
os.environ.setdefault("MUSE_AUTH", "off")
|
||||||
|
os.environ.setdefault("MUSE_DB", "dev/acquisition.db")
|
||||||
|
os.environ.setdefault("MUSE_SEARCH_POLLS", "2")
|
||||||
|
|
||||||
|
for f in ("dev/acquisition.db", "dev/acquisition.db-wal", "dev/acquisition.db-shm"):
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
import app # noqa: E402
|
||||||
|
|
||||||
|
ok = fail = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
global ok, fail
|
||||||
|
if cond:
|
||||||
|
ok += 1
|
||||||
|
print(f" PASS {name}")
|
||||||
|
else:
|
||||||
|
fail += 1
|
||||||
|
print(f" FAIL {name} {detail}")
|
||||||
|
|
||||||
|
|
||||||
|
# Searches poll on a 2s cadence; nothing here is actually waiting on a network.
|
||||||
|
async def _instant(_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
app.asyncio.sleep = _instant
|
||||||
|
app.init_db() # normally done in lifespan; there is no server here
|
||||||
|
|
||||||
|
|
||||||
|
# ── a slskd that behaves however the test needs ──────────────────────────
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def __init__(self, data=None, status=200):
|
||||||
|
self._data, self.status = data, status
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status >= 400:
|
||||||
|
raise RuntimeError(f"Server error '{self.status} Internal Server Error'")
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSlskd:
|
||||||
|
"""Records what was asked of it and answers from a script."""
|
||||||
|
|
||||||
|
def __init__(self, per_query=None, enqueue_status=None, transfers=None):
|
||||||
|
self.per_query = per_query or {} # query text -> list of responses
|
||||||
|
self.enqueue_status = enqueue_status or {} # username -> http status
|
||||||
|
self.transfers = transfers or []
|
||||||
|
self.searched = []
|
||||||
|
self.enqueued = []
|
||||||
|
self.deleted = []
|
||||||
|
self._last = None
|
||||||
|
|
||||||
|
def __call__(self, timeout=30.0):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def post(self, url, json=None):
|
||||||
|
if url == "/api/v0/searches":
|
||||||
|
self._last = json["searchText"]
|
||||||
|
self.searched.append(self._last)
|
||||||
|
return Resp({})
|
||||||
|
user = url.rsplit("/", 1)[-1]
|
||||||
|
st = self.enqueue_status.get(user, 200)
|
||||||
|
self.enqueued.append(user)
|
||||||
|
return Resp({}, st)
|
||||||
|
|
||||||
|
async def get(self, url, params=None):
|
||||||
|
if url.startswith("/api/v0/searches/"):
|
||||||
|
responses = self.per_query.get(self._last, [])
|
||||||
|
return Resp({"state": "Completed, ResponseLimitReached",
|
||||||
|
"responses": responses,
|
||||||
|
"fileCount": sum(len(r["files"]) for r in responses)})
|
||||||
|
return Resp(self.transfers)
|
||||||
|
|
||||||
|
async def delete(self, url, params=None):
|
||||||
|
self.deleted.append(url)
|
||||||
|
return Resp({})
|
||||||
|
|
||||||
|
|
||||||
|
def peer(username, filename, *, slot=True, queue=0, size=5_000_000, bitrate=320):
|
||||||
|
return {"username": username, "hasFreeUploadSlot": slot,
|
||||||
|
"queueLength": queue, "uploadSpeed": 1_000_000,
|
||||||
|
"files": [{"filename": filename, "size": size, "bitRate": bitrate}]}
|
||||||
|
|
||||||
|
|
||||||
|
def make_job(job_id, artist="Michael Jackson", title="Billie Jean"):
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("INSERT OR REPLACE INTO jobs (id, state, artist, title, album)"
|
||||||
|
" VALUES (?,?,?,?,?)", (job_id, "searching", artist, title, None))
|
||||||
|
|
||||||
|
|
||||||
|
def job_row(job_id):
|
||||||
|
with app.db() as c:
|
||||||
|
return c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("\nnormalisation")
|
||||||
|
check("accents fold to ASCII", app.norm("Beyoncé") == "beyonce",
|
||||||
|
repr(app.norm("Beyoncé")))
|
||||||
|
check("umlauts fold", app.norm("Björk") == "bjork", repr(app.norm("Björk")))
|
||||||
|
check("slashed o folds", app.norm("margø") == "margo", repr(app.norm("margø")))
|
||||||
|
check("apostrophes vanish, not split",
|
||||||
|
app.norm("Don't Stop") == "dont stop", repr(app.norm("Don't Stop")))
|
||||||
|
check("ampersand still separates", app.norm("R&B") == "r b", repr(app.norm("R&B")))
|
||||||
|
|
||||||
|
print("\nquery ladder")
|
||||||
|
qs = app.search_queries("Michael Jackson", "Billie Jean")
|
||||||
|
check("falls back to the bare title", "Billie Jean" in qs, qs)
|
||||||
|
check("full query is still tried first", qs[0] == "Michael Jackson Billie Jean", qs)
|
||||||
|
qs = app.search_queries("Frank Ocean", "Novacane (Explicit Version)")
|
||||||
|
check("strips a parenthetical suffix", "Frank Ocean Novacane" in qs, qs)
|
||||||
|
qs = app.search_queries("Post Sex Nachos", "Pop Tarts (feat. MARIS)")
|
||||||
|
check("strips a featuring credit", "Post Sex Nachos Pop Tarts" in qs, qs)
|
||||||
|
qs = app.search_queries("The Weeknd", "House Of Balloons / Glass Table Girls")
|
||||||
|
check("splits a slashed title", "House Of Balloons" in qs, qs)
|
||||||
|
check("ladder is capped", len(app.search_queries("A", "B")) <= app.MAX_QUERIES)
|
||||||
|
|
||||||
|
print("\nscoring")
|
||||||
|
avail_mp3 = app.score({"filename": r"x\Radiohead - Creep.mp3", "size": 1,
|
||||||
|
"bitRate": 320},
|
||||||
|
{"username": "u", "hasFreeUploadSlot": True,
|
||||||
|
"queueLength": 0, "uploadSpeed": 0},
|
||||||
|
"Radiohead", "Creep")
|
||||||
|
queued_flac = app.score({"filename": r"x\Radiohead - Creep.flac", "size": 1,
|
||||||
|
"bitRate": 1000},
|
||||||
|
{"username": "v", "hasFreeUploadSlot": False,
|
||||||
|
"queueLength": 400, "uploadSpeed": 0},
|
||||||
|
"Radiohead", "Creep")
|
||||||
|
check("an available MP3 beats a FLAC behind 400 people",
|
||||||
|
avail_mp3 > queued_flac, f"{avail_mp3:.0f} vs {queued_flac:.0f}")
|
||||||
|
check("artist found in the folder, not the filename",
|
||||||
|
app.match_ratio(r"Radiohead\Pablo Honey\02 Creep.flac",
|
||||||
|
"Radiohead", "Creep") == 1.0)
|
||||||
|
check("a matching artist cannot rescue the wrong song",
|
||||||
|
app.match_ratio(r"Radiohead\Pablo Honey\04 Karma Police.flac",
|
||||||
|
"Radiohead", "Creep") == 0.0)
|
||||||
|
check("reordered filename still matches",
|
||||||
|
app.match_ratio(r"x\Creep - Radiohead.mp3", "Radiohead", "Creep") == 1.0)
|
||||||
|
|
||||||
|
print("\ncandidate ladder")
|
||||||
|
fake = FakeSlskd(per_query={
|
||||||
|
"Billie Jean": [peer("alice", r"m\Michael Jackson\Billie Jean.flac"),
|
||||||
|
peer("bob", r"n\Michael Jackson\Billie Jean.mp3"),
|
||||||
|
peer("carol", r"o\Michael Jackson\Billie Jean.flac")],
|
||||||
|
})
|
||||||
|
app.slskd_client = fake
|
||||||
|
make_job("j1")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j1", "Michael Jackson", "Billie Jean"))
|
||||||
|
r = job_row("j1")
|
||||||
|
check("blocklisted artist recovered by the title rung",
|
||||||
|
"Billie Jean" in fake.searched, fake.searched)
|
||||||
|
check("job is downloading", r["state"] == "downloading", r["detail"])
|
||||||
|
check("runners-up were kept", len(json.loads(r["candidates"] or "[]")) == 2,
|
||||||
|
r["candidates"])
|
||||||
|
check("consecutive candidates are different peers",
|
||||||
|
len({c["username"] for c in json.loads(r["candidates"])}) == 2)
|
||||||
|
|
||||||
|
print("\nfallback on failure")
|
||||||
|
fake.enqueue_status = {}
|
||||||
|
asyncio.run(app.abandon(job_row("j1"), "Transfer rejected: File not shared."))
|
||||||
|
r = job_row("j1")
|
||||||
|
check("a rejection advances instead of failing",
|
||||||
|
r["state"] == "downloading", f"{r['state']} / {r['detail']}")
|
||||||
|
check("the failed peer is recorded",
|
||||||
|
"File not shared" in (r["tried"] or ""), r["tried"])
|
||||||
|
check("attempt counter advanced", r["attempts"] == 2, r["attempts"])
|
||||||
|
|
||||||
|
asyncio.run(app.abandon(job_row("j1"), "timed out"))
|
||||||
|
asyncio.run(app.abandon(job_row("j1"), "timed out"))
|
||||||
|
r = job_row("j1")
|
||||||
|
check("only fails once the ladder is exhausted", r["state"] == "failed", r["detail"])
|
||||||
|
check("failure names every attempt, not just the last",
|
||||||
|
"tried 3 peers" in (r["detail"] or ""), r["detail"])
|
||||||
|
|
||||||
|
print("\nattempt cap")
|
||||||
|
fake2 = FakeSlskd(per_query={"Creep": [
|
||||||
|
peer(f"u{i}", rf"x\Radiohead\Creep.flac") for i in range(8)]})
|
||||||
|
# every peer refuses to accept the transfer
|
||||||
|
fake2.enqueue_status = {f"u{i}": 500 for i in range(8)}
|
||||||
|
app.slskd_client = fake2
|
||||||
|
make_job("j2", "Radiohead", "Creep")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j2", "Radiohead", "Creep"))
|
||||||
|
r = job_row("j2")
|
||||||
|
check("gives up at MAX_ATTEMPTS, not at the end of the list",
|
||||||
|
r["state"] == "failed" and r["attempts"] == app.MAX_ATTEMPTS,
|
||||||
|
f"state={r['state']} attempts={r['attempts']}")
|
||||||
|
check("unreachable peers are reported as such",
|
||||||
|
"could not reach peer" in (r["tried"] or ""), r["detail"])
|
||||||
|
|
||||||
|
print("\nempty results")
|
||||||
|
fake3 = FakeSlskd(per_query={})
|
||||||
|
app.slskd_client = fake3
|
||||||
|
make_job("j3", "Nobody", "Nothing")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j3", "Nobody", "Nothing"))
|
||||||
|
r = job_row("j3")
|
||||||
|
check("says the search was empty, not that matching failed",
|
||||||
|
"Soulseek returned nothing" in (r["detail"] or ""), r["detail"])
|
||||||
|
check("names the queries it tried", "'Nothing'" in (r["detail"] or ""), r["detail"])
|
||||||
|
|
||||||
|
print("\ntransient slskd")
|
||||||
|
class Flaky(FakeSlskd):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(per_query={"Creep": [peer("z", r"x\Creep.flac")]})
|
||||||
|
self.n = 0
|
||||||
|
|
||||||
|
async def post(self, url, json=None):
|
||||||
|
if url == "/api/v0/searches":
|
||||||
|
self.n += 1
|
||||||
|
if self.n < 3:
|
||||||
|
raise RuntimeError("") # httpx timeouts stringify to nothing
|
||||||
|
return await FakeSlskd.post(self, url, json=json)
|
||||||
|
|
||||||
|
flaky = Flaky()
|
||||||
|
app.slskd_client = flaky
|
||||||
|
make_job("j4", "Radiohead", "Creep")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j4", "Radiohead", "Creep"))
|
||||||
|
check("a blip is retried rather than failing the job",
|
||||||
|
job_row("j4")["state"] == "downloading", job_row("j4")["detail"])
|
||||||
|
|
||||||
|
print("\nsearch hygiene")
|
||||||
|
check("finished searches are cleaned up", len(flaky.deleted) >= 1)
|
||||||
|
|
||||||
|
print("\nstall clock")
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("UPDATE jobs SET started_at=? WHERE id=?",
|
||||||
|
((datetime.now(timezone.utc) - timedelta(minutes=90)).isoformat(),
|
||||||
|
"j4"))
|
||||||
|
check("a 90-minute-old transfer reads as stalled",
|
||||||
|
app.stalled_for(job_row("j4")) > app.STALL_MINUTES,
|
||||||
|
app.stalled_for(job_row("j4")))
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("UPDATE jobs SET started_at=NULL WHERE id=?", ("j4",))
|
||||||
|
check("a job with no clock is not spuriously stalled",
|
||||||
|
app.stalled_for(job_row("j4")) == 0.0)
|
||||||
|
|
||||||
|
print(f"\n{ok} passed, {fail} failed")
|
||||||
|
return 1 if fail else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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
|
muse design system
|
||||||
|
|
||||||
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
|
||||||
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
|
The previous system sampled the album artwork and repainted the whole
|
||||||
colour. JS writes those three numbers and nothing else.
|
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 {
|
@theme inline {
|
||||||
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
--color-accent: var(--accent);
|
||||||
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
--color-accent-soft: var(--accent-soft);
|
||||||
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
--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-ink: var(--fg);
|
||||||
--color-dim: var(--dim);
|
--color-dim: var(--dim);
|
||||||
|
--color-mute: var(--mute);
|
||||||
--color-edge: var(--edge);
|
--color-edge: var(--edge);
|
||||||
--color-edge-hi: var(--edge-hi);
|
--color-edge-hi: var(--edge-hi);
|
||||||
--color-faint: var(--faint);
|
--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-ok: var(--ok);
|
||||||
--color-err: var(--err);
|
--color-err: var(--err);
|
||||||
--color-warn: var(--warn);
|
--color-warn: var(--warn);
|
||||||
|
|
||||||
--radius-glass: 14px;
|
--radius-control: 8px;
|
||||||
--radius-panel: 28px;
|
--radius-card: 12px;
|
||||||
--radius-control: 11px;
|
--radius-panel: 18px;
|
||||||
|
--radius-sheet: 22px;
|
||||||
--radius-pill: 999px;
|
--radius-pill: 999px;
|
||||||
|
|
||||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
/* One shared curve, so unrelated components feel like one physical system. */
|
||||||
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||||
and settle. A single shared curve keeps unrelated components feeling like
|
|
||||||
one physical system. */
|
|
||||||
--ease-spring: linear(
|
--ease-spring: linear(
|
||||||
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
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:
|
--font-sans:
|
||||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||||
"Segoe UI", Roboto, sans-serif;
|
"Segoe UI", Roboto, sans-serif;
|
||||||
@@ -45,9 +72,13 @@
|
|||||||
system-ui, "Segoe UI", Roboto, sans-serif;
|
system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
|
||||||
/* Type scale. The defining trait of Apple's typography is that tracking
|
/* Type scale. Tracking tightens as size grows -- display text at default
|
||||||
tightens as size grows — display text at default tracking looks loose and
|
tracking looks loose, small text at display tracking looks cramped. */
|
||||||
amateurish, and 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: 2.125rem;
|
||||||
--text-display--line-height: 1.08;
|
--text-display--line-height: 1.08;
|
||||||
--text-display--letter-spacing: -0.033em;
|
--text-display--letter-spacing: -0.033em;
|
||||||
@@ -74,88 +105,113 @@
|
|||||||
--text-caption: 0.6875rem;
|
--text-caption: 0.6875rem;
|
||||||
--text-caption--line-height: 1.35;
|
--text-caption--line-height: 1.35;
|
||||||
--text-caption--letter-spacing: 0.005em;
|
--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 {
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
/* Default artwork tint, used until a cover is sampled. */
|
||||||
--a-r: 120;
|
--a-r: 120;
|
||||||
--a-g: 140;
|
--a-g: 140;
|
||||||
--a-b: 255;
|
--a-b: 255;
|
||||||
|
|
||||||
--bg: #08090d;
|
--bg: #0a0b0f;
|
||||||
--fg: #f2f4f9;
|
--surface: #121319;
|
||||||
--dim: #9aa3b6;
|
--surface-2: #181a22;
|
||||||
--faint: rgb(255 255 255 / 0.07);
|
--surface-3: #20232c;
|
||||||
--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);
|
|
||||||
|
|
||||||
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
--fg: #f4f6fb;
|
||||||
dark background; the same amber on white fails contrast outright, which
|
--dim: #a0a8bb;
|
||||||
is exactly the text a reviewer most needs to read. */
|
--mute: #6d7488;
|
||||||
--ok: #3ddc84;
|
|
||||||
--err: #ff6b6b;
|
--faint: rgb(255 255 255 / 0.06);
|
||||||
--warn: #ffc95c;
|
--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;
|
/* 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
|
without this, blur alone reads as a flat grey rectangle. */
|
||||||
most of the difference between "translucent" and "material". */
|
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
|
||||||
|
|
||||||
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
/* 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. */
|
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);
|
--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.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
--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.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f5f8;
|
--bg: #f6f7f9;
|
||||||
--fg: #0e1116;
|
--surface: #ffffff;
|
||||||
--dim: #5a6274;
|
--surface-2: #f2f3f6;
|
||||||
--faint: rgb(10 14 25 / 0.07);
|
--surface-3: #e9ebf0;
|
||||||
--glass: rgb(255 255 255 / 0.7);
|
|
||||||
--glass-hi: rgb(255 255 255 / 0.88);
|
--fg: #0d1117;
|
||||||
--edge: rgb(10 14 25 / 0.09);
|
--dim: #596273;
|
||||||
--edge-hi: rgb(10 14 25 / 0.16);
|
--mute: #858da0;
|
||||||
--sheet: rgb(255 255 255 / 0.95);
|
|
||||||
--header: rgb(244 245 248 / 0.72);
|
--faint: rgb(10 14 25 / 0.05);
|
||||||
--scrim: rgb(255 255 255 / 0.78);
|
--edge: rgb(10 14 25 / 0.1);
|
||||||
--scrim-2: rgb(255 255 255 / 0.5);
|
--edge-hi: rgb(10 14 25 / 0.18);
|
||||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
|
||||||
/* On light material the specular rim is near-white and the shadows have
|
--material: rgb(246 247 249 / 0.72);
|
||||||
to lose most of their weight, or every card looks like it is hovering. */
|
--material-hi: rgb(255 255 255 / 0.88);
|
||||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
|
||||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
--scrim: rgb(255 255 255 / 0.8);
|
||||||
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
--scrim-2: rgb(255 255 255 / 0.45);
|
||||||
--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);
|
--accent: #0071e3;
|
||||||
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
--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. */
|
still reads as the same status when the theme flips. */
|
||||||
--ok: #0a7d43;
|
--ok: #0a7d43;
|
||||||
--err: #c62828;
|
--err: #c62828;
|
||||||
--warn: #8a5a00;
|
--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 {
|
@layer base {
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
@@ -167,8 +223,8 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
/* Headings take the display face and its tighter tracking automatically,
|
/* Headings take the display face and its tighter tracking automatically, so
|
||||||
so a component never has to remember to ask for it. */
|
a component never has to remember to ask for it. */
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
@@ -189,43 +245,48 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-accent-soft);
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility glass {
|
/* Vibrancy, for chrome that sits over scrolling content. */
|
||||||
background: var(--glass);
|
@utility material {
|
||||||
border: 1px solid var(--edge);
|
background: var(--material);
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
box-shadow: var(--spec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility glass-hi {
|
@utility material-hi {
|
||||||
background: var(--glass-hi);
|
background: var(--material-hi);
|
||||||
border: 1px solid var(--edge-hi);
|
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-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 {
|
@utility sheet {
|
||||||
background: var(--sheet);
|
background: var(--surface);
|
||||||
border: 1px solid var(--edge-hi);
|
border: 1px solid var(--edge-hi);
|
||||||
backdrop-filter: var(--blur);
|
|
||||||
-webkit-backdrop-filter: var(--blur);
|
|
||||||
box-shadow: var(--spec-hi), var(--e3);
|
box-shadow: var(--spec-hi), var(--e3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
|
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
|
||||||
hover feedback is barely perceptible frame to frame and obvious in
|
feedback is barely perceptible frame to frame and obvious in aggregate.
|
||||||
aggregate. Anything larger reads as a web page, not an interface. */
|
Anything larger reads as a web page, not an interface. */
|
||||||
@utility lift {
|
@utility lift {
|
||||||
transition:
|
transition:
|
||||||
transform 0.28s var(--ease-glass),
|
transform 0.28s var(--ease-out),
|
||||||
box-shadow 0.28s var(--ease-glass),
|
box-shadow 0.28s var(--ease-out),
|
||||||
border-color 0.28s var(--ease-glass),
|
border-color 0.28s var(--ease-out),
|
||||||
background 0.28s var(--ease-glass);
|
background 0.28s var(--ease-out);
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--spec-hi), var(--e2);
|
box-shadow: var(--spec-hi), var(--e2);
|
||||||
@@ -237,11 +298,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard focus only — a mouse click should never draw a ring. */
|
/* ONE focus treatment for the whole app. There used to be two that looked
|
||||||
@utility ring {
|
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 {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +329,6 @@
|
|||||||
overflow: hidden;
|
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 {
|
@utility no-scrollbar {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -278,14 +336,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="search"]::-webkit-search-cancel-button {
|
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
|
||||||
display: none;
|
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 {
|
@keyframes shimmer {
|
||||||
to {
|
to {
|
||||||
background-position: -220% 0;
|
background-position: -220% 0;
|
||||||
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
|
|||||||
.shimmer {
|
.shimmer {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
100deg,
|
100deg,
|
||||||
var(--glass) 30%,
|
var(--surface-2) 30%,
|
||||||
var(--glass-hi) 50%,
|
var(--surface-3) 50%,
|
||||||
var(--glass) 70%
|
var(--surface-2) 70%
|
||||||
);
|
);
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
animation: shimmer 1.4s linear infinite;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !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.",
|
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,
|
* 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
|
* 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,
|
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||||
* which is the look being aimed at anyway.
|
* 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 }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<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 * as React from "react";
|
||||||
import { motion } from "framer-motion";
|
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 { api, type Me, type Track } from "@/lib/api";
|
||||||
import { tint } from "@/lib/colour";
|
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 { SearchBar } from "@/components/search-bar";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { AlbumView } from "@/components/album-view";
|
import { AlbumView } from "@/components/album-view";
|
||||||
@@ -18,32 +18,18 @@ import { Requests, type JobsSnapshot } from "@/components/requests";
|
|||||||
import { Review } from "@/components/review";
|
import { Review } from "@/components/review";
|
||||||
import { DiscoverPanel } from "@/components/discover";
|
import { DiscoverPanel } from "@/components/discover";
|
||||||
import { Settings } from "@/components/settings";
|
import { Settings } from "@/components/settings";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { Welcome } from "@/components/welcome";
|
||||||
import { PlayerProvider } from "@/components/player";
|
import { PlayerProvider } from "@/components/player";
|
||||||
import { ToastProvider } from "@/components/toast";
|
import { ToastProvider } from "@/components/toast";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
type Tab =
|
import { Sidebar } from "@/components/shell/sidebar";
|
||||||
| "search"
|
import { TabBar } from "@/components/shell/tabbar";
|
||||||
| "discover"
|
import { Toolbar } from "@/components/shell/toolbar";
|
||||||
| "jobs"
|
import { StatusBanner } from "@/components/shell/status-banner";
|
||||||
| "review"
|
import { Button } from "@/components/ui/button";
|
||||||
| "bulk"
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
| "settings"
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||||
| "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",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
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() {
|
function Muse() {
|
||||||
const [tab, setTab] = React.useState<Tab>("search");
|
const [nav, go] = useNav();
|
||||||
const [view, setView] = React.useState<View>({ k: "welcome" });
|
const { rail } = useBreakpoint();
|
||||||
const [history, setHistory] = React.useState<View[]>([]);
|
const ok = useMotionOK();
|
||||||
|
|
||||||
const [results, setResults] = React.useState<Track[] | null>(null);
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [me, setMe] = React.useState<Me | null>(null);
|
const [me, setMe] = React.useState<Me | null>(null);
|
||||||
const [snap, setSnap] = React.useState<JobsSnapshot | 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(
|
const doSearch = React.useCallback(
|
||||||
async (q: string) => {
|
async (q: string) => {
|
||||||
if (!q.trim()) return;
|
if (!q.trim()) return;
|
||||||
go({ k: "results", q });
|
go({ tab: "search", q });
|
||||||
setResults(null);
|
setResults(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -99,8 +81,24 @@ function Muse() {
|
|||||||
[go],
|
[go],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
// Re-runs the query when the URL carries one, so a shared link and a browser
|
||||||
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
// 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 () => {
|
const refresh = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -129,191 +127,64 @@ function Muse() {
|
|||||||
(snap?.counts.searching ?? 0) +
|
(snap?.counts.searching ?? 0) +
|
||||||
(snap?.counts.downloading ?? 0);
|
(snap?.counts.downloading ?? 0);
|
||||||
const held = snap?.counts.review ?? 0;
|
const held = snap?.counts.review ?? 0;
|
||||||
|
|
||||||
const isAdmin = me?.role === "admin";
|
const isAdmin = me?.role === "admin";
|
||||||
|
|
||||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
// 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.
|
// is noise on a single-user install, which is most of them.
|
||||||
const quotaLeft =
|
const quotaLeft =
|
||||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
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 (
|
return (
|
||||||
<>
|
<div className="flex min-h-dvh">
|
||||||
<Aurora />
|
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
|
||||||
|
|
||||||
<header
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
<Toolbar me={me} quotaLeft={quotaLeft}>
|
||||||
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||||
>
|
</Toolbar>
|
||||||
<div className="mx-auto w-full max-w-[940px] px-4">
|
<StatusBanner />
|
||||||
<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>
|
|
||||||
|
|
||||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
<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)}>
|
||||||
{/* A sliding pill rather than an underline: the moving surface is
|
{nav.tab === "search" && (
|
||||||
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" && (
|
|
||||||
<>
|
<>
|
||||||
{history.length > 0 && (
|
{onBack && (
|
||||||
<motion.button
|
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
||||||
initial={{ opacity: 0, x: -8 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
onClick={back}
|
|
||||||
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
|
||||||
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
<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 ? (
|
(results === null ? (
|
||||||
<Skeletons />
|
<SkeletonRows n={6} />
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="py-12 text-center text-dim">
|
<EmptyState
|
||||||
No matches. Try adding the artist name.
|
icon={SearchX}
|
||||||
</div>
|
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) => (
|
{results.map((t, i) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={t.deezer_id}
|
key={t.deezer_id}
|
||||||
@@ -324,71 +195,22 @@ function Muse() {
|
|||||||
onQueued={refresh}
|
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} />}
|
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||||
{tab === "review" && <Review onDecided={refresh} />}
|
{nav.tab === "review" && <Review onDecided={refresh} />}
|
||||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
{nav.tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||||
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
</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>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
<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 { StatCard } from "@/components/admin/stat-card";
|
||||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||||
import { UsersTable } from "@/components/admin/users-table";
|
import { UsersTable } from "@/components/admin/users-table";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonCards } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function AdminPanel({ me }: { me: string | null }) {
|
export function AdminPanel({ me }: { me: string | null }) {
|
||||||
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
|
|||||||
}, [loadUsers]);
|
}, [loadUsers]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
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 s = sys.system;
|
||||||
const lib = s.disks.library;
|
const lib = s.disks.library;
|
||||||
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
|||||||
initial={{ opacity: 0, y: -6 }}
|
initial={{ opacity: 0, y: -6 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={cn(
|
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",
|
healthy ? "border-ok/30" : "border-err/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -141,7 +141,7 @@ function Breakdown({
|
|||||||
}) {
|
}) {
|
||||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||||
return (
|
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>
|
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{rows.slice(0, 8).map((r, i) => (
|
{rows.slice(0, 8).map((r, i) => (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function StatCard({
|
|||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
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="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
<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);
|
const [adding, setAdding] = React.useState(false);
|
||||||
|
|
||||||
return (
|
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 className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
@@ -35,7 +35,7 @@ export function UsersTable({
|
|||||||
anyone in — the row is.
|
anyone in — the row is.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
{adding ? "Cancel" : "Invite"}
|
{adding ? "Cancel" : "Invite"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -101,7 +101,7 @@ function UserRow({
|
|||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass-hi rounded-glass p-3",
|
"card rounded-card p-3",
|
||||||
u.disabled && "opacity-60",
|
u.disabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input =
|
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";
|
"text-ink outline-none focus:border-accent-line";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className="overflow-hidden"
|
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">
|
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||||
<span className="text-caption text-dim">
|
<span className="text-caption text-dim">
|
||||||
{provider === "github" ? "GitHub username" : "Google email"}
|
{provider === "github" ? "GitHub username" : "Google email"}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||||
import { tint } from "@/lib/colour";
|
import { tint } from "@/lib/colour";
|
||||||
import { Hero } from "@/components/hero";
|
import { Hero } from "@/components/hero";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/components/toast";
|
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({
|
export function AlbumView({
|
||||||
id,
|
id,
|
||||||
@@ -43,8 +44,16 @@ export function AlbumView({
|
|||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
if (err)
|
||||||
if (!data) return <Skeletons n={8} />;
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
tone="err"
|
||||||
|
title="Could not load album"
|
||||||
|
body={err}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!data) return <SkeletonRows n={8} />;
|
||||||
|
|
||||||
const { album, tracks } = data;
|
const { album, tracks } = data;
|
||||||
|
|
||||||
@@ -88,29 +97,42 @@ export function AlbumView({
|
|||||||
album.duration ? mmss(album.duration * 1000) : null,
|
album.duration ? mmss(album.duration * 1000) : null,
|
||||||
]}
|
]}
|
||||||
actions={
|
actions={
|
||||||
<Button onClick={getAll} disabled={batch !== "idle"}>
|
// Hidden rather than rendered as "Get all 0 tracks": Deezer serves
|
||||||
{batch === "idle"
|
// metadata for albums whose track list it has no data for, and an
|
||||||
? `Get all ${tracks.length} tracks`
|
// enabled button that queues nothing is worse than no button.
|
||||||
: batch === "busy"
|
tracks.length > 0 ? (
|
||||||
? "Queueing…"
|
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||||
: `Queued ${queued}`}
|
{batch === "idle"
|
||||||
</Button>
|
? `Get all ${tracks.length} tracks`
|
||||||
|
: batch === "busy"
|
||||||
|
? "Queueing…"
|
||||||
|
: `Queued ${queued}`}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<motion.div layout className="flex flex-col gap-[7px]">
|
{tracks.length === 0 ? (
|
||||||
{tracks.map((t, i) => (
|
<EmptyState
|
||||||
<TrackRow
|
icon={Disc}
|
||||||
key={t.deezer_id}
|
title="Deezer has no track list for this release"
|
||||||
track={t}
|
body="Its catalogue has the album but not its songs. Search for a track by name instead."
|
||||||
index={i}
|
/>
|
||||||
showNumber
|
) : (
|
||||||
showArtist={false}
|
<div className="flex flex-col gap-row">
|
||||||
showAlbum={false}
|
{tracks.map((t, i) => (
|
||||||
onQueued={onQueued}
|
<TrackRow
|
||||||
/>
|
key={t.deezer_id}
|
||||||
))}
|
track={t}
|
||||||
</motion.div>
|
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 { tint } from "@/lib/colour";
|
||||||
import { Hero } from "@/components/hero";
|
import { Hero } from "@/components/hero";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export function ArtistView({
|
export function ArtistView({
|
||||||
id,
|
id,
|
||||||
@@ -42,7 +42,7 @@ export function ArtistView({
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
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;
|
const { artist, tracks, albums } = data;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function ArtistView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHead>Top tracks</SectionHead>
|
<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) => (
|
{tracks.map((t, i) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={t.deezer_id}
|
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
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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>
|
<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">
|
<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)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder={PLACEHOLDER}
|
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]
|
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||||
text-ink outline-none transition
|
text-ink outline-none transition
|
||||||
placeholder:text-dim/65
|
placeholder:text-dim/65
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
|||||||
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
||||||
|
|
||||||
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
|
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
|
* 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);
|
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);
|
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
||||||
if (empty)
|
if (empty)
|
||||||
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||||
onClick={() => onArtist(a.name)}
|
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 ? (
|
{a.picture ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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">
|
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function Hero({
|
|||||||
initial={{ opacity: 0, y: 14 }}
|
initial={{ opacity: 0, y: 14 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
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"
|
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||||
>
|
>
|
||||||
{art && (
|
{art && (
|
||||||
@@ -72,7 +72,7 @@ export function Hero({
|
|||||||
{facts.filter(Boolean).map((f, i) => (
|
{facts.filter(Boolean).map((f, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
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}
|
{f}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={stop}
|
onClick={stop}
|
||||||
aria-label="Stop preview"
|
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
|
text-ink cursor-pointer transition hover:border-accent-line
|
||||||
hover:text-accent"
|
hover:text-accent"
|
||||||
>
|
>
|
||||||
|
|||||||
+103
-110
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { api, type Job } from "@/lib/api";
|
import { Inbox, ChevronRight } from "lucide-react";
|
||||||
import { useToast } from "@/components/toast";
|
import { type Job } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type JobsSnapshot = {
|
export type JobsSnapshot = {
|
||||||
@@ -13,142 +18,130 @@ export type JobsSnapshot = {
|
|||||||
pending: number;
|
pending: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PILL: Record<Job["state"], string> = {
|
/**
|
||||||
imported: "text-ok border-ok/40 bg-ok/10",
|
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||||
failed: "text-err border-err/40 bg-err/10",
|
* differed only by the word inside, so the three stages of a request looked
|
||||||
// Held for review, not in flight and not finished — it needs a person, so
|
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||||
// it reads as the accent rather than as another in-progress amber.
|
* now read as distinct.
|
||||||
review: "text-accent border-accent-line bg-accent-soft",
|
*/
|
||||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||||
searching: "text-warn border-warn/40 bg-warn/10",
|
queued: { tone: "neutral", label: "Queued" },
|
||||||
queued: "text-warn border-warn/40 bg-warn/10",
|
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 }) {
|
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;
|
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 active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||||
const done = counts.imported ?? 0;
|
const done = counts.imported ?? 0;
|
||||||
const failed = counts.failed ?? 0;
|
const failed = counts.failed ?? 0;
|
||||||
|
const held = counts.review ?? 0;
|
||||||
const total = active + done;
|
const total = active + done;
|
||||||
|
|
||||||
const held = counts.review ?? 0;
|
|
||||||
const stats: [string, number, string][] = [
|
const stats: [string, number, string][] = [
|
||||||
["In flight", active, "text-accent"],
|
["In flight", active, "text-accent"],
|
||||||
["Imported", done, "text-ok"],
|
["In library", done, "text-ok"],
|
||||||
["Held", held, held > 0 ? "text-warn" : ""],
|
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||||
["Failed", failed, "text-err"],
|
["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 (
|
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]) => (
|
{stats.map(([label, n, colour]) => (
|
||||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
<Card key={label} pad="sm">
|
||||||
<motion.b
|
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||||
key={n}
|
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||||
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">
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
{active > 0 && total > 0 && (
|
||||||
{active > 0 && total > 0 && (
|
<Progress
|
||||||
<motion.div
|
value={(done / total) * 100}
|
||||||
initial={{ opacity: 0, height: 0 }}
|
className="mb-4"
|
||||||
animate={{ opacity: 1, height: 4 }}
|
label="Requests completed"
|
||||||
exit={{ opacity: 0, height: 0 }}
|
/>
|
||||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
)}
|
||||||
>
|
|
||||||
<motion.i
|
|
||||||
className="block h-1 rounded-full bg-accent"
|
|
||||||
animate={{ width: `${(done / total) * 100}%` }}
|
|
||||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{jobs.length === 0 ? (
|
{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}>
|
<AnimatePresence initial={false}>
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => {
|
||||||
<motion.div
|
const s = STATE[j.state] ?? STATE.queued;
|
||||||
key={j.id}
|
return (
|
||||||
layout
|
<motion.button
|
||||||
initial={{ opacity: 0, y: 10 }}
|
key={j.id}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
layout={ok}
|
||||||
exit={{ opacity: 0, scale: 0.97 }}
|
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||||
>
|
transition={spring}
|
||||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
onClick={() => setOpen(j.id)}
|
||||||
{j.cover && (
|
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
|
||||||
<img
|
text-left transition-colors hover:bg-[var(--surface-2)]"
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(j.state === "searching" || j.state === "downloading") && (
|
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
|
||||||
<motion.span
|
{j.cover && (
|
||||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
animate={{ opacity: [1, 0.25, 1] }}
|
<img
|
||||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
src={j.cover}
|
||||||
/>
|
alt=""
|
||||||
)}
|
loading="lazy"
|
||||||
{j.state}
|
className="h-full w-full object-cover"
|
||||||
</span>
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
||||||
</motion.div>
|
/>
|
||||||
))}
|
)}
|
||||||
|
</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>
|
</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 { api, bytes, type ReviewItem } from "@/lib/api";
|
||||||
import { useToast } from "@/components/toast";
|
import { useToast } from "@/components/toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
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)
|
if (items.length === 0)
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
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" />
|
<Check className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@ function ReviewCard({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
||||||
transition={{ type: "spring", stiffness: 340, damping: 30 }}
|
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">
|
<div className="flex gap-4">
|
||||||
{item.cover ? (
|
{item.cover ? (
|
||||||
@@ -123,11 +123,11 @@ function ReviewCard({
|
|||||||
<img
|
<img
|
||||||
src={item.cover}
|
src={item.cover}
|
||||||
alt=""
|
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
|
<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"
|
border border-edge bg-faint text-dim"
|
||||||
>
|
>
|
||||||
<FileAudio className="h-6 w-6" />
|
<FileAudio className="h-6 w-6" />
|
||||||
@@ -145,7 +145,7 @@ function ReviewCard({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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
|
<ShieldQuestion className="h-3 w-3" /> what was found
|
||||||
</div>
|
</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 };
|
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
||||||
|
|
||||||
export function SearchBar({
|
export function SearchBar({
|
||||||
|
initial,
|
||||||
busy,
|
busy,
|
||||||
onSearch,
|
onSearch,
|
||||||
onAlbum,
|
onAlbum,
|
||||||
onArtist,
|
onArtist,
|
||||||
}: {
|
}: {
|
||||||
|
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
||||||
|
initial?: string;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onSearch: (q: string) => void;
|
onSearch: (q: string) => void;
|
||||||
onAlbum: (id: number) => void;
|
onAlbum: (id: number) => void;
|
||||||
onArtist: (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 [items, setItems] = React.useState<Item[]>([]);
|
||||||
const [open, setOpen] = React.useState(false);
|
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 [sel, setSel] = React.useState(-1);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -76,7 +93,7 @@ export function SearchBar({
|
|||||||
];
|
];
|
||||||
setItems(next);
|
setItems(next);
|
||||||
setSel(-1);
|
setSel(-1);
|
||||||
setOpen(next.length > 0);
|
setOpen(typed.current && next.length > 0);
|
||||||
} catch {
|
} catch {
|
||||||
/* aborted or offline — leave the previous list alone */
|
/* aborted or offline — leave the previous list alone */
|
||||||
}
|
}
|
||||||
@@ -129,9 +146,9 @@ export function SearchBar({
|
|||||||
<div className="relative z-50">
|
<div className="relative z-50">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||||
"transition-all duration-250 ease-glass",
|
"transition-all duration-250 ease-out",
|
||||||
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
||||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -140,7 +157,10 @@ export function SearchBar({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => {
|
||||||
|
typed.current = true;
|
||||||
|
setQ(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={key}
|
onKeyDown={key}
|
||||||
onFocus={() => items.length && setOpen(true)}
|
onFocus={() => items.length && setOpen(true)}
|
||||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||||
@@ -161,7 +181,7 @@ export function SearchBar({
|
|||||||
}}
|
}}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
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" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{anonymous && (
|
{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
|
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>{" "}
|
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
|
||||||
to <code className="font-mono text-[12px]">github</code> or{" "}
|
to <code className="font-mono text-[12px]">github</code> or{" "}
|
||||||
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section className="glass rounded-panel p-5">
|
<section className="card rounded-panel p-5">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<Mail className="h-4 w-4 text-accent" />
|
<Mail className="h-4 w-4 text-accent" />
|
||||||
<h2 className="text-headline">Notifications</h2>
|
<h2 className="text-headline">Notifications</h2>
|
||||||
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!me.mail_configured && (
|
{!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
|
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{" "}
|
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.
|
<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()}
|
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
|
||||||
disabled={anonymous}
|
disabled={anonymous}
|
||||||
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
|
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
|
text-body outline-none transition focus:border-accent-line
|
||||||
disabled:opacity-45"
|
disabled:opacity-45"
|
||||||
/>
|
/>
|
||||||
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={notify}
|
aria-checked={notify}
|
||||||
disabled={anonymous}
|
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"
|
text-left disabled:cursor-default disabled:opacity-45"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="glass rounded-panel p-5">
|
<section className="card rounded-panel p-5">
|
||||||
<h2 className="mb-3 text-headline">Account</h2>
|
<h2 className="mb-3 text-headline">Account</h2>
|
||||||
{anonymous ? (
|
{anonymous ? (
|
||||||
<p className="text-callout text-dim">
|
<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 * as React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useMotionOK, spring } from "@/lib/motion";
|
||||||
|
|
||||||
type Toast = { id: number; msg: string; err?: boolean };
|
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
|
||||||
type Ctx = (msg: string, err?: boolean) => void;
|
type Action = { label: string; run: () => void };
|
||||||
|
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
|
||||||
|
|
||||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||||
export const useToast = () => React.useContext(ToastCtx);
|
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 }) {
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [items, setItems] = React.useState<Toast[]>([]);
|
const [items, setItems] = React.useState<Toast[]>([]);
|
||||||
|
const ok = useMotionOK();
|
||||||
|
|
||||||
const push = React.useCallback<Ctx>((msg, err) => {
|
const drop = React.useCallback(
|
||||||
const id = Date.now() + Math.random();
|
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
|
||||||
setItems((x) => [...x, { id, msg, err }]);
|
[],
|
||||||
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
);
|
||||||
}, []);
|
|
||||||
|
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 (
|
return (
|
||||||
<ToastCtx.Provider value={push}>
|
<ToastCtx.Provider value={push}>
|
||||||
{children}
|
{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>
|
<AnimatePresence>
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
layout
|
layout={ok}
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
transition={spring}
|
||||||
className={cn(
|
className={cn(
|
||||||
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
|
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
|
||||||
"max-w-[calc(100vw-32px)]",
|
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
|
||||||
t.err && "border-err/50 text-err",
|
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>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export function TrackRow({
|
|||||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||||
whileHover={{ y: -1 }}
|
whileHover={{ y: -1 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
|
||||||
isPlaying && "border-accent-line bg-accent-soft",
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const button = cva(
|
const button = cva(
|
||||||
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
|
||||||
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
"select-none whitespace-nowrap focus-ring " +
|
||||||
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
"transition-[background,border-color,color,transform,box-shadow] " +
|
||||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
"duration-150 ease-out active:scale-[0.97] " +
|
||||||
|
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
|
||||||
|
"disabled:active:scale-100",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// Solid accent. The label is near-black because the accent is clamped
|
// The accent is fixed now, not sampled from artwork, so a solid button
|
||||||
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
// has a known contrast ratio and can carry a plain white label. It
|
||||||
// to stay in step or this button becomes unreadable on some albums.
|
// used to be near-black text on a colour taken from the album cover,
|
||||||
primary:
|
// which only worked because colour.ts clamped the luminance.
|
||||||
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
|
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
|
||||||
"active:scale-[0.97] active:brightness-95",
|
secondary:
|
||||||
ghost:
|
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
|
||||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
"hover:bg-[var(--surface-2)]",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
"bg-transparent border border-edge-hi text-ink " +
|
||||||
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
|
||||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
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: {
|
size: {
|
||||||
sm: "text-caption px-3 py-1.5 rounded-pill",
|
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
|
||||||
md: "text-callout px-4.5 py-2.5 rounded-control",
|
sm: "text-callout px-3 py-1.5 rounded-control",
|
||||||
pill: "text-callout px-4 py-2 rounded-pill",
|
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" },
|
defaultVariants: { variant: "primary", size: "md" },
|
||||||
@@ -45,9 +51,7 @@ export interface ButtonProps
|
|||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild, ...props }, ref) => {
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
|
||||||
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,6 +129,15 @@ export type Job = {
|
|||||||
duration: number | null;
|
duration: number | null;
|
||||||
requested_by: string | null;
|
requested_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** The peer currently serving this transfer, if one is. */
|
||||||
|
username: string | null;
|
||||||
|
remote_file: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
match_why: string | null;
|
||||||
|
/** Peers already attempted for this request, and why each one fell through. */
|
||||||
|
tried: { username: string; filename: string; reason: string }[];
|
||||||
|
attempts: number;
|
||||||
|
remaining: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Usage = { requests: number; storage: number };
|
export type Usage = { requests: number; storage: number };
|
||||||
@@ -248,8 +257,18 @@ function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|||||||
|
|
||||||
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
||||||
|
|
||||||
|
export type Health = {
|
||||||
|
ok: boolean;
|
||||||
|
slskd?: number | string;
|
||||||
|
logged_in?: boolean;
|
||||||
|
shared_files?: number | null;
|
||||||
|
library_writable?: boolean;
|
||||||
|
downloads_readable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
me: () => get<Me>("/api/me"),
|
me: () => get<Me>("/api/me"),
|
||||||
|
health: () => get<Health>("/api/health"),
|
||||||
search: (q: string) =>
|
search: (q: string) =>
|
||||||
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
suggest: (q: string, signal?: AbortSignal) =>
|
suggest: (q: string, signal?: AbortSignal) =>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useReducedMotion, type Transition, type Variants } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One motion vocabulary.
|
||||||
|
*
|
||||||
|
* There were four different stagger formulas and five entrance offsets across
|
||||||
|
* the components, so nothing quite moved like anything else. More importantly,
|
||||||
|
* the `prefers-reduced-motion` block in globals.css only governs CSS: Framer
|
||||||
|
* animates via the CSSOM from JS, so every entrance, the aurora blobs and the
|
||||||
|
* pulsing status dots all ran at full strength for people who had explicitly
|
||||||
|
* asked them not to. `useMotionOK()` is the fix, and every animated component
|
||||||
|
* consults it.
|
||||||
|
*/
|
||||||
|
export function useMotionOK() {
|
||||||
|
return !useReducedMotion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple's motion reads as weight, not duration: overshoot slightly, settle. */
|
||||||
|
export const spring: Transition = { type: "spring", stiffness: 420, damping: 34 };
|
||||||
|
export const springSoft: Transition = { type: "spring", stiffness: 260, damping: 30 };
|
||||||
|
|
||||||
|
export const rise: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 8 },
|
||||||
|
shown: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fade: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
shown: { opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capped, so item 400 of a long list does not wait eight seconds for its turn.
|
||||||
|
*/
|
||||||
|
export function stagger(i: number, step = 0.025, max = 0.3) {
|
||||||
|
return Math.min(i * step, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrance props for a list item, honouring the reduced-motion setting.
|
||||||
|
*
|
||||||
|
* Note what the reduced-motion branch does NOT do: return `{}`. Dropping the
|
||||||
|
* props entirely removes `animate` while `initial` has already painted
|
||||||
|
* opacity 0, and the element stays invisible forever. `useReducedMotion()`
|
||||||
|
* also reports false on the first render and flips after mount, so that lands
|
||||||
|
* exactly on the people who asked for less motion and shows them a blank page.
|
||||||
|
* Instead the element is mounted already in its final state.
|
||||||
|
*/
|
||||||
|
export function item(i: number, ok: boolean) {
|
||||||
|
if (!ok) return { initial: false as const, animate: "shown" as const, variants: rise };
|
||||||
|
return {
|
||||||
|
initial: "hidden" as const,
|
||||||
|
animate: "shown" as const,
|
||||||
|
variants: rise,
|
||||||
|
transition: { ...springSoft, delay: stagger(i) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrance for a whole view. Deliberately translate-only, with no fade.
|
||||||
|
*
|
||||||
|
* This wrapper contains the entire screen, so anything that can leave it at
|
||||||
|
* opacity 0 leaves the app looking broken rather than looking unanimated. A
|
||||||
|
* background tab throttles rAF and freezes an in-flight animation part way —
|
||||||
|
* it resumes on focus, but a stalled fade is a blank page and a stalled slide
|
||||||
|
* is a few pixels of offset nobody notices. Fades stay on individual items,
|
||||||
|
* where a stall is cosmetic.
|
||||||
|
*/
|
||||||
|
const slide: Variants = {
|
||||||
|
hidden: { y: 8 },
|
||||||
|
shown: { y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function panel(ok: boolean) {
|
||||||
|
if (!ok) return { initial: false as const, animate: "shown" as const, variants: slide };
|
||||||
|
return {
|
||||||
|
initial: "hidden" as const,
|
||||||
|
animate: "shown" as const,
|
||||||
|
variants: slide,
|
||||||
|
transition: { duration: 0.22, ease: [0.22, 0.9, 0.28, 1] as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL state for a statically exported app.
|
||||||
|
*
|
||||||
|
* There was none: `tab` and the album/artist view lived in React state alone,
|
||||||
|
* so a refresh always dumped you back at the welcome screen, the browser Back
|
||||||
|
* button did nothing, and no view could be linked to anyone. `output: "export"`
|
||||||
|
* rules out dynamic route segments, so the state rides in the query string and
|
||||||
|
* is driven through the History API.
|
||||||
|
*/
|
||||||
|
export type Tab =
|
||||||
|
| "search"
|
||||||
|
| "discover"
|
||||||
|
| "jobs"
|
||||||
|
| "review"
|
||||||
|
| "bulk"
|
||||||
|
| "settings"
|
||||||
|
| "admin";
|
||||||
|
|
||||||
|
export type Nav = {
|
||||||
|
tab: Tab;
|
||||||
|
view?: "album" | "artist";
|
||||||
|
id?: string;
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: Tab[] = ["search", "discover", "jobs", "review", "bulk", "settings", "admin"];
|
||||||
|
|
||||||
|
export function parse(search: string): Nav {
|
||||||
|
const p = new URLSearchParams(search);
|
||||||
|
const raw = p.get("tab");
|
||||||
|
const tab = (TABS as string[]).includes(raw || "") ? (raw as Tab) : "search";
|
||||||
|
const v = p.get("view");
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
view: v === "album" || v === "artist" ? v : undefined,
|
||||||
|
id: p.get("id") || undefined,
|
||||||
|
q: p.get("q") || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function href(n: Nav): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (n.tab !== "search") p.set("tab", n.tab);
|
||||||
|
if (n.view) p.set("view", n.view);
|
||||||
|
if (n.id) p.set("id", n.id);
|
||||||
|
if (n.q) p.set("q", n.q);
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `?${s}` : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the URL and writes it back, without Next's router.
|
||||||
|
*
|
||||||
|
* `next/navigation`'s useSearchParams forces a Suspense boundary under static
|
||||||
|
* export and re-renders the tree on every keystroke of a search query. The
|
||||||
|
* History API is enough for what this needs and keeps Back working.
|
||||||
|
*
|
||||||
|
* useSyncExternalStore rather than useState-in-an-effect: the URL is external
|
||||||
|
* state that React does not own, and this is the hook designed for that. It
|
||||||
|
* also gets the static-export case right — the prerendered HTML has no URL to
|
||||||
|
* read, so the server snapshot is the empty query, and the client subscribes
|
||||||
|
* from its first render without a mismatch.
|
||||||
|
*/
|
||||||
|
const LOCATION_EVENT = "muse:navigate";
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
window.addEventListener("popstate", cb);
|
||||||
|
window.addEventListener(LOCATION_EVENT, cb);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", cb);
|
||||||
|
window.removeEventListener(LOCATION_EVENT, cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSearch = () => window.location.search;
|
||||||
|
const serverSearch = () => "";
|
||||||
|
|
||||||
|
export function useNav(): [Nav, (n: Nav, replace?: boolean) => void] {
|
||||||
|
const search = React.useSyncExternalStore(subscribe, clientSearch, serverSearch);
|
||||||
|
const nav = React.useMemo(() => parse(search), [search]);
|
||||||
|
|
||||||
|
const go = React.useCallback((n: Nav, replace = false) => {
|
||||||
|
const url = href(n);
|
||||||
|
if (replace) window.history.replaceState(null, "", url);
|
||||||
|
else window.history.pushState(null, "", url);
|
||||||
|
// pushState does not fire popstate, so tell our own subscribers.
|
||||||
|
window.dispatchEvent(new Event(LOCATION_EVENT));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [nav, go];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user