Compare commits
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/
|
||||
.DS_Store
|
||||
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
|
||||
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
|
||||
offline, not a bad request, so retrying later usually just works.
|
||||
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
can actually serve you (free upload slot, short queue), then filename
|
||||
plausibility, then bitrate. A marginally better file behind 200 queued users
|
||||
loses to a good-enough one available now.
|
||||
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
|
||||
first — a free upload slot and a short queue — then format preference, then
|
||||
filename plausibility, then bitrate. A marginally better file behind 200 queued
|
||||
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
|
||||
|
||||
@@ -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_DB` | `/config/muse.db` | job history |
|
||||
| `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_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
|
||||
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
||||
|
||||
@@ -26,13 +26,16 @@ file on disk, two names — the same trick the *arrs use.
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -42,7 +45,7 @@ from urllib.parse import urlencode
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Query, Request
|
||||
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
|
||||
RedirectResponse)
|
||||
RedirectResponse, Response)
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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.
|
||||
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.
|
||||
#
|
||||
# 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
|
||||
# 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")
|
||||
|
||||
@@ -334,6 +367,28 @@ SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
NOISE = re.compile(r"[^a-z0-9]+")
|
||||
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
|
||||
# longer than that once a stranger has appended their own notes, and the
|
||||
# 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:
|
||||
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 ───────────────────────────
|
||||
@@ -382,7 +501,7 @@ def init_db():
|
||||
c.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
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,
|
||||
artist TEXT, title TEXT, album TEXT,
|
||||
year TEXT, track INTEGER,
|
||||
@@ -426,7 +545,15 @@ def init_db():
|
||||
("duration", "INTEGER"),
|
||||
("confidence", "REAL"),
|
||||
("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:
|
||||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||||
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
|
||||
number alone gives the reviewer nothing to act on.
|
||||
"""
|
||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
stem = norm(base.rsplit(".", 1)[0])
|
||||
stem = norm(bare_name(filename))
|
||||
path = norm(filename.replace("\\", "/"))
|
||||
na, nt = norm(artist), norm(title)
|
||||
why: list[str] = []
|
||||
|
||||
# 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.
|
||||
t_ratio = difflib.SequenceMatcher(None, nt, stem).ratio()
|
||||
t_ratio = similarity(nt, stem)
|
||||
if nt and nt in stem:
|
||||
t_score, t_ratio = 1.0, max(t_ratio, 1.0)
|
||||
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"))
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Ordering intent: correct format first, then actually-gettable (a peer with
|
||||
a free slot and a short queue beats a marginally better file behind 200
|
||||
people), then filename plausibility, then bitrate.
|
||||
Ordering intent: actually-gettable first, then correct format, 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"])
|
||||
if ext not in FORMATS:
|
||||
return None
|
||||
|
||||
base = candidate["filename"].replace("\\", "/").rsplit("/", 1)[-1]
|
||||
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)
|
||||
ratio = match_ratio(candidate["filename"], artist, title)
|
||||
if ratio < MIN_MATCH:
|
||||
return None
|
||||
|
||||
s = 0.0
|
||||
s += (len(FORMATS) - FORMATS.index(ext)) * 100 # format preference
|
||||
s += 60 if peer.get("hasFreeUploadSlot") else 0
|
||||
s += max(0, 40 - peer.get("queueLength", 0)) # short queues
|
||||
s += min(peer.get("uploadSpeed", 0) / 50_000, 20) # capped speed bonus
|
||||
s += ratio * 50
|
||||
s += 220 if peer.get("hasFreeUploadSlot") else 0
|
||||
# Unsaturated: the old `max(0, 40 - queueLength)` scored a 40-deep queue
|
||||
# and a 5000-deep one identically, which is the difference between waiting
|
||||
# a minute and never being served at all.
|
||||
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
|
||||
return s
|
||||
|
||||
|
||||
async def pick_and_enqueue(job_id: str, artist: str, title: str,
|
||||
album: str | None = None):
|
||||
"""Search slskd for one track and queue the best candidate."""
|
||||
want = norm(f"{artist} {title}")
|
||||
PAREN = re.compile(r"[\[(\{][^\])}]*[\])}]")
|
||||
FEAT = re.compile(r"\b(?:feat|ft|featuring)\b\.?.*$", re.I)
|
||||
ARTICLE = re.compile(r"^(?:the|a|an|los|las|les)\s+", re.I)
|
||||
|
||||
|
||||
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())
|
||||
async with slskd_client() as sc:
|
||||
for attempt in range(SEARCH_RETRIES):
|
||||
try:
|
||||
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()
|
||||
break
|
||||
except Exception as e:
|
||||
set_state(job_id, "failed", f"slskd search failed: {e}")
|
||||
return
|
||||
if attempt == SEARCH_RETRIES - 1:
|
||||
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
|
||||
# searches trickle in; results at t=5s are much worse than at t=15s.
|
||||
results = {}
|
||||
for _ in range(20):
|
||||
results: dict = {}
|
||||
finished = False
|
||||
for _ in range(SEARCH_POLLS):
|
||||
await asyncio.sleep(2)
|
||||
try:
|
||||
rr = await sc.get(f"/api/v0/searches/{search_id}",
|
||||
params={"includeResponses": "true"})
|
||||
rr.raise_for_status()
|
||||
results = rr.json()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
# Swallowed silently before, which made a broken slskd look
|
||||
# identical to a track nobody happens to share.
|
||||
log.debug("poll %r: %s", text, errstr(e))
|
||||
continue
|
||||
if str(results.get("state", "")).startswith("Completed"):
|
||||
finished = True
|
||||
break
|
||||
|
||||
best, best_score = None, -1.0
|
||||
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.
|
||||
if finished:
|
||||
try:
|
||||
await sc.delete(f"/api/v0/searches/{search_id}")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug("cleanup %r: %s", text, errstr(e))
|
||||
else:
|
||||
log.info("search %r unfinished after %ds, leaving it for slskd to close",
|
||||
text, SEARCH_POLLS * 2)
|
||||
return results
|
||||
|
||||
if not best:
|
||||
set_state(job_id, "failed",
|
||||
f"no {'/'.join(FORMATS)} candidate matched (searched {results.get('fileCount', 0)} files)")
|
||||
return
|
||||
|
||||
peer, f = best
|
||||
def spread_by_peer(ranked: list[tuple[float, dict, dict]]) -> list[dict]:
|
||||
"""Order candidates so consecutive attempts land on different peers.
|
||||
|
||||
Straight score order routinely stacks three files from one well-stocked
|
||||
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:
|
||||
er = await sc.post(f"/api/v0/transfers/downloads/{peer['username']}",
|
||||
json=[{"filename": f["filename"], "size": f["size"]}])
|
||||
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:
|
||||
set_state(job_id, "failed", f"enqueue failed: {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",
|
||||
f"from {cand['username']} (score {cand['score']:.0f}{extra})",
|
||||
username=cand["username"], remote_file=cand["filename"],
|
||||
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
|
||||
|
||||
# Confidence is recorded now, while the candidate list is still in
|
||||
# hand, but it gates the IMPORT rather than the download. Holding the
|
||||
# transfer instead would mean the peer is often gone by the time
|
||||
# someone reviews it; this way the file is already on disk, approving
|
||||
# is instant, and rejecting is a delete.
|
||||
conf, why = confidence(f["filename"], artist, title, album)
|
||||
set_state(job_id, "downloading",
|
||||
f"from {peer['username']} (score {best_score:.0f})",
|
||||
username=peer["username"], remote_file=f["filename"],
|
||||
size=f["size"], confidence=conf, match_why="; ".join(why))
|
||||
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 ───────────────────────────
|
||||
@@ -867,14 +1199,23 @@ def tag_and_file(job: sqlite3.Row, src: Path) -> Path:
|
||||
return dest
|
||||
|
||||
|
||||
def find_downloaded(remote_file: str) -> Path | None:
|
||||
"""Locate the completed file. slskd nests it under the remote's parent
|
||||
directory name, so match on basename rather than guessing the path."""
|
||||
base = remote_file.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
def downloaded_index() -> dict[str, Path]:
|
||||
"""Every completed file, by basename.
|
||||
|
||||
slskd nests a download under the remote's parent directory name, so the
|
||||
basename is the only reliable key. Built once per poll cycle and shared by
|
||||
every job in it: the previous version walked the whole tree separately for
|
||||
each in-flight job, every ten seconds, which on a large downloads
|
||||
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() and p.name == base:
|
||||
return p
|
||||
return None
|
||||
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 ───────────────────────────
|
||||
@@ -903,8 +1244,64 @@ async def search_worker(n: int):
|
||||
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():
|
||||
"""Poll slskd for in-flight downloads and import the finished ones."""
|
||||
missing: collections.Counter = collections.Counter()
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
try:
|
||||
@@ -912,28 +1309,36 @@ async def import_worker():
|
||||
jobs = c.execute(
|
||||
"SELECT * FROM jobs WHERE state='downloading'").fetchall()
|
||||
if not jobs:
|
||||
missing.clear()
|
||||
continue
|
||||
|
||||
async with slskd_client() as sc:
|
||||
r = await sc.get("/api/v0/transfers/downloads")
|
||||
r.raise_for_status()
|
||||
payload = r.json()
|
||||
index = transfer_index(r.json())
|
||||
|
||||
# Flatten slskd's user -> directories -> files nesting.
|
||||
states: dict[str, str] = {}
|
||||
for user in payload:
|
||||
for d in user.get("directories", []):
|
||||
for f in d.get("files", []):
|
||||
states[f.get("filename", "")] = str(f.get("state", ""))
|
||||
# One directory walk per cycle, shared by every job. This used to be
|
||||
# a full recursive rglob per stuck job, every ten seconds, forever.
|
||||
done_files: dict[str, Path] | None = None
|
||||
|
||||
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:
|
||||
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:
|
||||
continue # still being moved out of incomplete
|
||||
# Record the real size, not the size the peer advertised —
|
||||
# storage quotas are computed from this column.
|
||||
# Normally just still being moved out of incomplete, but
|
||||
# unbounded before: a misconfigured DOWNLOAD_DIR meant
|
||||
# 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:
|
||||
actual = src.stat().st_size
|
||||
except OSError:
|
||||
@@ -941,11 +1346,11 @@ async def import_worker():
|
||||
|
||||
conf = job["confidence"]
|
||||
if conf is not None and conf < AUTO_CONFIDENCE:
|
||||
# Hold it. The file stays in downloads, untagged and
|
||||
# unlinked, until someone approves or rejects it.
|
||||
band = ("almost certainly wrong"
|
||||
if conf < REVIEW_CONFIDENCE else "worth a look")
|
||||
set_state(job["id"], "review",
|
||||
f"{conf:.0%} confident — {job['match_why'] or ''}",
|
||||
size=actual)
|
||||
f"{conf:.0%} confident, {band} -- "
|
||||
f"{job['match_why'] or ''}", size=actual)
|
||||
await notify_review(job)
|
||||
continue
|
||||
|
||||
@@ -953,10 +1358,36 @@ async def import_worker():
|
||||
set_state(job["id"], "imported", str(dest),
|
||||
imported_path=str(dest), size=actual)
|
||||
await notify_imported(job, dest)
|
||||
|
||||
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:
|
||||
log.warning("worker: %s", e)
|
||||
log.warning("import worker: %s", errstr(e))
|
||||
|
||||
|
||||
# ─────────────────────────── api ───────────────────────────
|
||||
@@ -1094,20 +1525,91 @@ async def healthz():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1"><title>muse</title>
|
||||
</head><body style="margin:0;min-height:100vh;display:grid;place-items:center;
|
||||
background:#08090d;color:#f2f4f9;font:15px/1.5 -apple-system,BlinkMacSystemFont,
|
||||
ui-sans-serif,system-ui,sans-serif">
|
||||
<div style="text-align:center;padding:32px">
|
||||
<div style="font-size:26px;font-weight:700;letter-spacing:-.5px">muse</div>
|
||||
<div style="color:#9aa3b6;font-size:13px;margin:6px 0 26px">Sign in to continue</div>
|
||||
{buttons}</div></body></html>"""
|
||||
# The sign-in page is the first screen anyone sees, and for a while it was the
|
||||
# only one built out of hardcoded hex with no relation to the rest of the app.
|
||||
# It cannot use the Next bundle -- /_next/* sits behind the session on purpose,
|
||||
# so an unauthenticated visitor never receives it -- so it gets one small
|
||||
# stylesheet of its own, served from a public path. `style-src 'self'` in the
|
||||
# CSP permits exactly that; an inline <style> block would be refused.
|
||||
LOGIN_CSS = """
|
||||
:root{
|
||||
--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;'
|
||||
'padding:12px 26px;min-width:230px;border-radius:12px;text-decoration:none;'
|
||||
'background:rgb(255 255 255/.06);border:1px solid rgb(255 255 255/.14);'
|
||||
'color:#f2f4f9;font-weight:600">Continue with {label}</a>')
|
||||
CHOOSER = """<!doctype html><html lang=en><head><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<meta name=theme-color media="(prefers-color-scheme: light)" content="#f6f7f9">
|
||||
<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")
|
||||
@@ -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):
|
||||
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 src:
|
||||
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")
|
||||
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
||||
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(
|
||||
f"SELECT state, COUNT(*) n FROM jobs {where} GROUP BY state",
|
||||
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"}
|
||||
|
||||
|
||||
@@ -1980,7 +2502,7 @@ async def retry(job_id: str, request: Request):
|
||||
just works."""
|
||||
u = current_user(request)
|
||||
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()
|
||||
if not row:
|
||||
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" \
|
||||
and row["requested_by"] != u["username"]:
|
||||
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", "")
|
||||
await work_q.put(job_id)
|
||||
return {"ok": True}
|
||||
@@ -2339,6 +2874,19 @@ async def health():
|
||||
r = await sc.get("/api/v0/application")
|
||||
detail["slskd"] = r.status_code
|
||||
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:
|
||||
detail["slskd"] = errstr(e)
|
||||
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
|
||||
|
||||
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
||||
from whatever album is on screen; every tinted surface derives from them,
|
||||
so the page takes on the palette of the music instead of a fixed brand
|
||||
colour. JS writes those three numbers and nothing else.
|
||||
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
|
||||
|
||||
The previous system sampled the album artwork and repainted the whole
|
||||
interface from it. That is the inverse of how a native application behaves
|
||||
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
|
||||
only saturated thing on screen -- and it is most of why this read as a
|
||||
themed web page rather than an app.
|
||||
|
||||
So there are two colours now:
|
||||
|
||||
--accent fixed. Controls, selection, focus rings. Never sampled.
|
||||
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
|
||||
|
||||
That split also closes a real bug: the focus ring used to be drawn in the
|
||||
sampled colour, so its contrast against the page depended on which album
|
||||
you happened to be looking at.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@theme inline {
|
||||
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-line: var(--accent-line);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
|
||||
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
|
||||
--color-ink: var(--fg);
|
||||
--color-dim: var(--dim);
|
||||
--color-mute: var(--mute);
|
||||
--color-edge: var(--edge);
|
||||
--color-edge-hi: var(--edge-hi);
|
||||
--color-faint: var(--faint);
|
||||
|
||||
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
|
||||
for the sidebar and toolbar; previously `glass` was on everything, which
|
||||
is why nothing read as layered. */
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-3: var(--surface-3);
|
||||
|
||||
--color-ok: var(--ok);
|
||||
--color-err: var(--err);
|
||||
--color-warn: var(--warn);
|
||||
|
||||
--radius-glass: 14px;
|
||||
--radius-panel: 28px;
|
||||
--radius-control: 11px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 12px;
|
||||
--radius-panel: 18px;
|
||||
--radius-sheet: 22px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
||||
and settle. A single shared curve keeps unrelated components feeling like
|
||||
one physical system. */
|
||||
/* One shared curve, so unrelated components feel like one physical system. */
|
||||
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
--ease-spring: linear(
|
||||
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
||||
);
|
||||
|
||||
/* System faces on purpose. next/font self-hosts a webfont, which would make
|
||||
the Docker build reach out to Google, and `font-src 'self'` in the CSP
|
||||
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
|
||||
this is used from, which is the target anyway. */
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
@@ -45,9 +72,13 @@
|
||||
system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
|
||||
/* Type scale. The defining trait of Apple's typography is that tracking
|
||||
tightens as size grows — display text at default tracking looks loose and
|
||||
amateurish, and small text at display tracking looks cramped. */
|
||||
/* Type scale. Tracking tightens as size grows -- display text at default
|
||||
tracking looks loose, small text at display tracking looks cramped. */
|
||||
--text-hero: 2.75rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--text-hero--letter-spacing: -0.038em;
|
||||
--text-hero--font-weight: 700;
|
||||
|
||||
--text-display: 2.125rem;
|
||||
--text-display--line-height: 1.08;
|
||||
--text-display--letter-spacing: -0.033em;
|
||||
@@ -74,88 +105,113 @@
|
||||
--text-caption: 0.6875rem;
|
||||
--text-caption--line-height: 1.35;
|
||||
--text-caption--letter-spacing: 0.005em;
|
||||
|
||||
/* Named layout constants. These were arbitrary literals repeated across
|
||||
files: max-w-[940px] twice, gap-[7px] five times. */
|
||||
--spacing-row: 7px;
|
||||
--container-3xl: 1280px;
|
||||
--sidebar-w: 240px;
|
||||
--rail-w: 68px;
|
||||
--toolbar-h: 52px;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Default artwork tint, used until a cover is sampled. */
|
||||
--a-r: 120;
|
||||
--a-g: 140;
|
||||
--a-b: 255;
|
||||
|
||||
--bg: #08090d;
|
||||
--fg: #f2f4f9;
|
||||
--dim: #9aa3b6;
|
||||
--faint: rgb(255 255 255 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.055);
|
||||
--glass-hi: rgb(255 255 255 / 0.085);
|
||||
--edge: rgb(255 255 255 / 0.1);
|
||||
--edge-hi: rgb(255 255 255 / 0.17);
|
||||
--sheet: rgb(16 18 26 / 0.86);
|
||||
--header: rgb(8 9 13 / 0.62);
|
||||
--scrim: rgb(8 9 13 / 0.74);
|
||||
--scrim-2: rgb(8 9 13 / 0.4);
|
||||
--blur: saturate(180%) blur(24px);
|
||||
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
||||
--bg: #0a0b0f;
|
||||
--surface: #121319;
|
||||
--surface-2: #181a22;
|
||||
--surface-3: #20232c;
|
||||
|
||||
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
||||
dark background; the same amber on white fails contrast outright, which
|
||||
is exactly the text a reviewer most needs to read. */
|
||||
--ok: #3ddc84;
|
||||
--err: #ff6b6b;
|
||||
--warn: #ffc95c;
|
||||
--fg: #f4f6fb;
|
||||
--dim: #a0a8bb;
|
||||
--mute: #6d7488;
|
||||
|
||||
--faint: rgb(255 255 255 / 0.06);
|
||||
--edge: rgb(255 255 255 / 0.09);
|
||||
--edge-hi: rgb(255 255 255 / 0.16);
|
||||
|
||||
/* Vibrancy, for sidebar and toolbar only. */
|
||||
--material: rgb(18 19 25 / 0.72);
|
||||
--material-hi: rgb(24 26 34 / 0.86);
|
||||
--blur: saturate(180%) blur(20px);
|
||||
|
||||
--scrim: rgb(10 11 15 / 0.76);
|
||||
--scrim-2: rgb(10 11 15 / 0.35);
|
||||
|
||||
/* Fixed accent. Apple's system blue, which stays legible on both grounds
|
||||
and is independent of whatever is playing. */
|
||||
--accent: #0a84ff;
|
||||
--accent-soft: rgb(10 132 255 / 0.16);
|
||||
--accent-line: rgb(10 132 255 / 0.45);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--ok: #30d158;
|
||||
--err: #ff453a;
|
||||
--warn: #ff9f0a;
|
||||
|
||||
/* The specular edge. A real frosted panel catches light along its top rim;
|
||||
without this, blur alone reads as a flat grey rectangle. This one line is
|
||||
most of the difference between "translucent" and "material". */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
||||
without this, blur alone reads as a flat grey rectangle. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||
|
||||
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
||||
element, and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
||||
element and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f5f8;
|
||||
--fg: #0e1116;
|
||||
--dim: #5a6274;
|
||||
--faint: rgb(10 14 25 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.7);
|
||||
--glass-hi: rgb(255 255 255 / 0.88);
|
||||
--edge: rgb(10 14 25 / 0.09);
|
||||
--edge-hi: rgb(10 14 25 / 0.16);
|
||||
--sheet: rgb(255 255 255 / 0.95);
|
||||
--header: rgb(244 245 248 / 0.72);
|
||||
--scrim: rgb(255 255 255 / 0.78);
|
||||
--scrim-2: rgb(255 255 255 / 0.5);
|
||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
||||
/* On light material the specular rim is near-white and the shadows have
|
||||
to lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
|
||||
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f3f6;
|
||||
--surface-3: #e9ebf0;
|
||||
|
||||
--fg: #0d1117;
|
||||
--dim: #596273;
|
||||
--mute: #858da0;
|
||||
|
||||
--faint: rgb(10 14 25 / 0.05);
|
||||
--edge: rgb(10 14 25 / 0.1);
|
||||
--edge-hi: rgb(10 14 25 / 0.18);
|
||||
|
||||
--material: rgb(246 247 249 / 0.72);
|
||||
--material-hi: rgb(255 255 255 / 0.88);
|
||||
|
||||
--scrim: rgb(255 255 255 / 0.8);
|
||||
--scrim-2: rgb(255 255 255 / 0.45);
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-soft: rgb(0 113 227 / 0.12);
|
||||
--accent-line: rgb(0 113 227 / 0.4);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
|
||||
still reads as the same status when the theme flips. */
|
||||
--ok: #0a7d43;
|
||||
--err: #c62828;
|
||||
--warn: #8a5a00;
|
||||
|
||||
/* On light material the specular rim is near-white and the shadows have to
|
||||
lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
@@ -167,8 +223,8 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Headings take the display face and its tighter tracking automatically,
|
||||
so a component never has to remember to ask for it. */
|
||||
/* Headings take the display face and its tighter tracking automatically, so
|
||||
a component never has to remember to ask for it. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@@ -189,43 +245,48 @@
|
||||
color: inherit;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--edge);
|
||||
/* Vibrancy, for chrome that sits over scrolling content. */
|
||||
@utility material {
|
||||
background: var(--material);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec);
|
||||
}
|
||||
|
||||
@utility glass-hi {
|
||||
background: var(--glass-hi);
|
||||
border: 1px solid var(--edge-hi);
|
||||
@utility material-hi {
|
||||
background: var(--material-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi);
|
||||
}
|
||||
|
||||
/* An opaque content surface. The default for cards, rows and panels. */
|
||||
@utility card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
box-shadow: var(--spec), var(--e1);
|
||||
}
|
||||
|
||||
@utility sheet {
|
||||
background: var(--sheet);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi), var(--e3);
|
||||
}
|
||||
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
|
||||
hover feedback is barely perceptible frame to frame and obvious in
|
||||
aggregate. Anything larger reads as a web page, not an interface. */
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
|
||||
feedback is barely perceptible frame to frame and obvious in aggregate.
|
||||
Anything larger reads as a web page, not an interface. */
|
||||
@utility lift {
|
||||
transition:
|
||||
transform 0.28s var(--ease-glass),
|
||||
box-shadow 0.28s var(--ease-glass),
|
||||
border-color 0.28s var(--ease-glass),
|
||||
background 0.28s var(--ease-glass);
|
||||
transform 0.28s var(--ease-out),
|
||||
box-shadow 0.28s var(--ease-out),
|
||||
border-color 0.28s var(--ease-out),
|
||||
background 0.28s var(--ease-out);
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--spec-hi), var(--e2);
|
||||
@@ -237,11 +298,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus only — a mouse click should never draw a ring. */
|
||||
@utility ring {
|
||||
/* ONE focus treatment for the whole app. There used to be two that looked
|
||||
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
|
||||
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
|
||||
@utility focus-ring {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,12 +329,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Very light noise over the gradients — large flat blurs band visibly on
|
||||
8-bit panels without it. */
|
||||
@utility grain {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
@@ -278,14 +336,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the
|
||||
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine —
|
||||
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
|
||||
is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine -- CSP
|
||||
governs markup, not the CSSOM -- which is why Framer Motion works. */
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
|
||||
.shimmer {
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
var(--glass) 30%,
|
||||
var(--glass-hi) 50%,
|
||||
var(--glass) 70%
|
||||
var(--surface-2) 30%,
|
||||
var(--surface-3) 50%,
|
||||
var(--surface-2) 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Framer's animations are JS-driven and ignore this block entirely, which is
|
||||
why every component also consults useReducedMotion(). This covers the CSS
|
||||
half: shimmer, spinners, and every utility transition above. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -6,19 +6,26 @@ export const metadata: Metadata = {
|
||||
description: "Request a song, get it in your library, correctly tagged.",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#08090d",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
/**
|
||||
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
||||
* but it makes the Docker build reach out to Google at build time, and
|
||||
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||
* which is the look being aimed at anyway.
|
||||
*
|
||||
* themeColor is a pair rather than a single hex: it paints the browser's own
|
||||
* chrome on mobile, and a hardcoded dark value left a black status bar sitting
|
||||
* above a white page for anyone using the light theme.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f6f7f9" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0f" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Without this, a bad path drops the user on Next's unstyled white error page,
|
||||
* which does not look like it belongs to anything.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||
<div>
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.08em] text-mute">404</p>
|
||||
<h1 className="mt-2 text-display">That page isn’t here</h1>
|
||||
<p className="mx-auto mt-2 max-w-[42ch] text-body text-dim">
|
||||
The link may be stale, or the view may have been renamed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring mt-6 inline-flex rounded-control bg-accent px-4 py-2
|
||||
text-callout font-semibold text-[var(--accent-ink)]"
|
||||
>
|
||||
Back to search
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+99
-277
@@ -2,13 +2,13 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
||||
import { ArrowLeft, SearchX } from "lucide-react";
|
||||
|
||||
import { api, type Me, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNav, type Tab } from "@/lib/nav";
|
||||
import { panel, useMotionOK } from "@/lib/motion";
|
||||
|
||||
import { Aurora } from "@/components/aurora";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { AlbumView } from "@/components/album-view";
|
||||
@@ -18,32 +18,18 @@ import { Requests, type JobsSnapshot } from "@/components/requests";
|
||||
import { Review } from "@/components/review";
|
||||
import { DiscoverPanel } from "@/components/discover";
|
||||
import { Settings } from "@/components/settings";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { Welcome } from "@/components/welcome";
|
||||
import { PlayerProvider } from "@/components/player";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
type Tab =
|
||||
| "search"
|
||||
| "discover"
|
||||
| "jobs"
|
||||
| "review"
|
||||
| "bulk"
|
||||
| "settings"
|
||||
| "admin";
|
||||
type View =
|
||||
| { k: "welcome" }
|
||||
| { k: "results"; q: string }
|
||||
| { k: "album"; id: number }
|
||||
| { k: "artist"; id: number };
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
import { Sidebar } from "@/components/shell/sidebar";
|
||||
import { TabBar } from "@/components/shell/tabbar";
|
||||
import { Toolbar } from "@/components/shell/toolbar";
|
||||
import { StatusBanner } from "@/components/shell/status-banner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
@@ -55,36 +41,32 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
|
||||
function useBreakpoint() {
|
||||
const [w, setW] = React.useState(1280);
|
||||
React.useEffect(() => {
|
||||
const on = () => setW(window.innerWidth);
|
||||
on();
|
||||
window.addEventListener("resize", on);
|
||||
return () => window.removeEventListener("resize", on);
|
||||
}, []);
|
||||
return { rail: w >= 768 && w < 1024 };
|
||||
}
|
||||
|
||||
function Muse() {
|
||||
const [tab, setTab] = React.useState<Tab>("search");
|
||||
const [view, setView] = React.useState<View>({ k: "welcome" });
|
||||
const [history, setHistory] = React.useState<View[]>([]);
|
||||
const [nav, go] = useNav();
|
||||
const { rail } = useBreakpoint();
|
||||
const ok = useMotionOK();
|
||||
|
||||
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [me, setMe] = React.useState<Me | null>(null);
|
||||
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
||||
|
||||
const go = React.useCallback((next: View) => {
|
||||
setView((cur) => {
|
||||
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
||||
return next;
|
||||
});
|
||||
setTab("search");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const back = () => {
|
||||
setHistory((h) => {
|
||||
const prev = h[h.length - 1];
|
||||
if (prev) setView(prev);
|
||||
return h.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const doSearch = React.useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) return;
|
||||
go({ k: "results", q });
|
||||
go({ tab: "search", q });
|
||||
setResults(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -99,8 +81,24 @@ function Muse() {
|
||||
[go],
|
||||
);
|
||||
|
||||
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
||||
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
||||
// Re-runs the query when the URL carries one, so a shared link and a browser
|
||||
// Back both restore an actual result set rather than an empty screen.
|
||||
const lastQ = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (nav.tab !== "search" || nav.view || !nav.q) return;
|
||||
if (lastQ.current === nav.q) return;
|
||||
lastQ.current = nav.q;
|
||||
void doSearch(nav.q);
|
||||
}, [nav.tab, nav.view, nav.q, doSearch]);
|
||||
|
||||
const openAlbum = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
const openArtist = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -129,191 +127,64 @@ function Muse() {
|
||||
(snap?.counts.searching ?? 0) +
|
||||
(snap?.counts.downloading ?? 0);
|
||||
const held = snap?.counts.review ?? 0;
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
||||
// is noise on a single-user install, which is most of them.
|
||||
const quotaLeft =
|
||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
||||
|
||||
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
|
||||
// Review appears exactly when something is waiting on you; an always-present
|
||||
// tab that is empty nine days in ten is chrome.
|
||||
const visible = (t: Tab) =>
|
||||
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
|
||||
|
||||
const onBack = nav.view
|
||||
? () => go({ tab: "search", q: nav.q })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Toolbar me={me} quotaLeft={quotaLeft}>
|
||||
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
</Toolbar>
|
||||
<StatusBanner />
|
||||
|
||||
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
|
||||
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
|
||||
{nav.tab === "search" && (
|
||||
<>
|
||||
<Aurora />
|
||||
|
||||
<header
|
||||
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
||||
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[940px] px-4">
|
||||
<div className="mb-3.5 flex items-center gap-3">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 12, scale: 1.06 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
||||
border border-accent-line bg-accent-soft text-accent"
|
||||
>
|
||||
<Music2 className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-title leading-none">muse</h1>
|
||||
<p className="mt-0.5 text-caption text-dim">
|
||||
search · request · it appears in Navidrome
|
||||
</p>
|
||||
</div>
|
||||
{me?.user && (
|
||||
<div className="flex items-center gap-2 text-callout text-dim">
|
||||
{quotaLeft !== null && (
|
||||
<span
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className={cn(
|
||||
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
|
||||
quotaLeft === 0
|
||||
? "border-err/50 text-err"
|
||||
: quotaLeft <= 5
|
||||
? "border-warn/50 text-warn"
|
||||
: "border-edge text-dim",
|
||||
)}
|
||||
>
|
||||
{quotaLeft} left
|
||||
</span>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full border border-edge"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden font-medium text-ink sm:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
{me.role && me.role !== "user" && (
|
||||
<span
|
||||
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
|
||||
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
|
||||
>
|
||||
{me.role}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="ring rounded-md px-1 transition-colors hover:text-ink"
|
||||
>
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
|
||||
{/* A sliding pill rather than an underline: the moving surface is
|
||||
the selection itself, so the eye tracks one object across the
|
||||
row instead of watching two things fade. layoutId does the
|
||||
interpolation, so it stays correct when tabs appear or vanish
|
||||
with a role change. */}
|
||||
{/* The mask is the scroll affordance: on a narrow screen the last
|
||||
tab fades out at the edge instead of being clipped flat, which
|
||||
is the only cue that there is more to the right. */}
|
||||
<nav
|
||||
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
|
||||
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
|
||||
sm:[mask-image:none]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["search", "Search", 0],
|
||||
["discover", "Discover", 0],
|
||||
["jobs", "Requests", active],
|
||||
// Only exists when it is actionable. An always-present tab
|
||||
// that is empty nine days in ten is chrome; this one appears
|
||||
// exactly when something is waiting on you.
|
||||
...(held > 0 || tab === "review"
|
||||
? ([["review", "Review", held]] as [Tab, string, number][])
|
||||
: []),
|
||||
["bulk", "Import", 0],
|
||||
["settings", "Settings", 0],
|
||||
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
|
||||
] as [Tab, string, number][]
|
||||
).map(([k, label, badge]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
aria-current={tab === k ? "page" : undefined}
|
||||
className={cn(
|
||||
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
|
||||
"text-callout font-medium transition-colors duration-200",
|
||||
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{tab === k && (
|
||||
<motion.span
|
||||
layoutId="tab-pill"
|
||||
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
|
||||
transition={{ type: "spring", stiffness: 460, damping: 38 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
{label}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
|
||||
k === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
||||
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
||||
the incoming panel until the outgoing one's exit animation reports
|
||||
finished, which makes navigation depend on an animation completing.
|
||||
Anything that stalls the frame loop — a backgrounded tab, a browser
|
||||
that throttles rAF — then leaves the UI stuck on the old panel with
|
||||
no way forward. A cosmetic transition should never gate navigation. */}
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{tab === "search" && (
|
||||
<>
|
||||
{history.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={back}
|
||||
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
||||
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
||||
>
|
||||
{onBack && (
|
||||
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</motion.button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||
{nav.view === "album" && nav.id && (
|
||||
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{nav.view === "artist" && nav.id && (
|
||||
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
|
||||
{view.k === "results" &&
|
||||
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
|
||||
|
||||
{!nav.view &&
|
||||
nav.q &&
|
||||
(results === null ? (
|
||||
<Skeletons />
|
||||
<SkeletonRows n={6} />
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">
|
||||
No matches. Try adding the artist name.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No matches"
|
||||
body="Try adding the artist name, or fewer words."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
{results.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
@@ -324,71 +195,22 @@ function Muse() {
|
||||
onQueued={refresh}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{view.k === "album" && (
|
||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{tab === "review" && <Review onDecided={refresh} />}
|
||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{nav.tab === "review" && <Review onDecided={refresh} />}
|
||||
{nav.tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
</motion.div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</motion.div>
|
||||
|
||||
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/l
|
||||
import { StatCard } from "@/components/admin/stat-card";
|
||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonCards } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminPanel({ me }: { me: string | null }) {
|
||||
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
|
||||
}, [loadUsers]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!sys || !users) return <Skeletons n={6} />;
|
||||
if (!sys || !users) return <SkeletonCards n={4} />;
|
||||
|
||||
const s = sys.system;
|
||||
const lib = s.disks.library;
|
||||
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
|
||||
"card flex items-center gap-2.5 rounded-card px-4 py-2.5 text-callout",
|
||||
healthy ? "border-ok/30" : "border-err/40",
|
||||
)}
|
||||
>
|
||||
@@ -141,7 +141,7 @@ function Breakdown({
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.slice(0, 8).map((r, i) => (
|
||||
|
||||
@@ -26,7 +26,7 @@ export function StatCard({
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||
className="glass rounded-glass p-3.5"
|
||||
className="card rounded-card p-3.5"
|
||||
>
|
||||
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||
|
||||
@@ -22,7 +22,7 @@ export function UsersTable({
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
@@ -35,7 +35,7 @@ export function UsersTable({
|
||||
anyone in — the row is.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
<Button size="pill" variant={adding ? "secondary" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{adding ? "Cancel" : "Invite"}
|
||||
</Button>
|
||||
@@ -101,7 +101,7 @@ function UserRow({
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
className={cn(
|
||||
"glass-hi rounded-glass p-3",
|
||||
"card rounded-card p-3",
|
||||
u.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
};
|
||||
|
||||
const input =
|
||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"text-ink outline-none focus:border-accent-line";
|
||||
|
||||
return (
|
||||
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
||||
<div className="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
|
||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||
<span className="text-caption text-dim">
|
||||
{provider === "github" ? "GitHub username" : "Google email"}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { AlertCircle, Disc } from "lucide-react";
|
||||
|
||||
export function AlbumView({
|
||||
id,
|
||||
@@ -43,8 +44,16 @@ export function AlbumView({
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (err)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
tone="err"
|
||||
title="Could not load album"
|
||||
body={err}
|
||||
/>
|
||||
);
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { album, tracks } = data;
|
||||
|
||||
@@ -88,6 +97,10 @@ export function AlbumView({
|
||||
album.duration ? mmss(album.duration * 1000) : null,
|
||||
]}
|
||||
actions={
|
||||
// Hidden rather than rendered as "Get all 0 tracks": Deezer serves
|
||||
// metadata for albums whose track list it has no data for, and an
|
||||
// enabled button that queues nothing is worse than no button.
|
||||
tracks.length > 0 ? (
|
||||
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||
{batch === "idle"
|
||||
? `Get all ${tracks.length} tracks`
|
||||
@@ -95,10 +108,18 @@ export function AlbumView({
|
||||
? "Queueing…"
|
||||
: `Queued ${queued}`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{tracks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Disc}
|
||||
title="Deezer has no track list for this release"
|
||||
body="Its catalogue has the album but not its songs. Search for a track by name instead."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
@@ -110,7 +131,8 @@ export function AlbumView({
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export function ArtistView({
|
||||
id,
|
||||
@@ -42,7 +42,7 @@ export function ArtistView({
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { artist, tracks, albums } = data;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ArtistView({
|
||||
/>
|
||||
|
||||
<SectionHead>Top tracks</SectionHead>
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<motion.div layout className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
|
||||
* the artwork sets, so the page takes on the palette of whatever is on screen.
|
||||
*
|
||||
* Kept faint on purpose: at full strength the gradient competes with the album
|
||||
* art it is derived from, and the glass rows lose contrast against it.
|
||||
*/
|
||||
export function Aurora() {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
|
||||
<motion.i
|
||||
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
|
||||
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
|
||||
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
|
||||
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-30 blur-[110px]
|
||||
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
|
||||
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
|
||||
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
|
||||
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* Channel-rotated so the palette has range rather than one flat wash,
|
||||
but faint — a full-strength complement fights the artwork. */}
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-[0.14] blur-[110px]
|
||||
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
|
||||
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
|
||||
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-5"
|
||||
className="card rounded-panel p-5"
|
||||
>
|
||||
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
|
||||
<p className="mb-3.5 max-w-[62ch] text-callout text-dim">
|
||||
@@ -61,7 +61,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={PLACEHOLDER}
|
||||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||||
className="min-h-[170px] w-full resize-y rounded-card border border-edge
|
||||
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||
text-ink outline-none transition
|
||||
placeholder:text-dim/65
|
||||
|
||||
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
||||
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
||||
|
||||
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonGrid } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Recommendations and a personal recap, both derived from muse's OWN request
|
||||
@@ -24,7 +24,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
api.recap().then(setR).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
if (!d) return <Skeletons n={4} />;
|
||||
if (!d) return <SkeletonGrid n={8} />;
|
||||
|
||||
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
||||
if (empty)
|
||||
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||
onClick={() => onArtist(a.name)}
|
||||
className="glass lift ring group cursor-pointer rounded-panel p-3 text-center"
|
||||
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
|
||||
>
|
||||
{a.picture ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-4"
|
||||
className="card rounded-panel p-4"
|
||||
>
|
||||
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{title}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Hero({
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
className="card relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||
>
|
||||
{art && (
|
||||
@@ -72,7 +72,7 @@ export function Hero({
|
||||
{facts.filter(Boolean).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
className="card rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckCircle2, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
import { Sheet } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/toast";
|
||||
|
||||
/**
|
||||
* Why a request is where it is.
|
||||
*
|
||||
* Previously a failure was a red word in a list plus a three-second toast, and
|
||||
* the reason was raw text — "transfer Completed, Errored" — joined with a dot
|
||||
* after the album name in 13px dim type. Since the backend now walks a ladder
|
||||
* of candidates instead of giving up on the first peer, there is an actual
|
||||
* story to tell: who was tried, what each one said, and what is left.
|
||||
*/
|
||||
export function JobSheet({
|
||||
job,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
job: Job | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const retry = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.retry(job.id);
|
||||
toast("Requeued");
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const conf = job?.confidence;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={!!job}
|
||||
onOpenChange={(v) => !v && onClose()}
|
||||
title={job ? job.title : ""}
|
||||
description={job ? [job.artist, job.album].filter(Boolean).join(" · ") : undefined}
|
||||
footer={
|
||||
job?.state === "failed" ? (
|
||||
<Button onClick={retry} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} Try again
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{job && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatePill state={job.state} />
|
||||
{job.attempts > 0 && (
|
||||
<span className="text-caption text-mute nums">
|
||||
{job.attempts} peer{job.attempts === 1 ? "" : "s"} tried
|
||||
{job.state !== "failed" && job.remaining > 0
|
||||
? ` · ${job.remaining} left to try`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.detail && (
|
||||
<p className="text-callout text-dim">{job.detail}</p>
|
||||
)}
|
||||
|
||||
{conf != null && (
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<h3 className="text-callout font-medium">Match confidence</h3>
|
||||
<span className="text-callout text-dim nums">{Math.round(conf * 100)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={conf * 100}
|
||||
tone={conf >= 0.95 ? "ok" : conf >= 0.7 ? "warn" : "err"}
|
||||
/>
|
||||
{job.match_why && (
|
||||
<p className="mt-1.5 text-caption text-mute">{job.match_why}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.remote_file && (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-callout font-medium">File</h3>
|
||||
<p className="break-all rounded-control bg-[var(--surface-2)] p-2.5 font-mono text-caption text-dim">
|
||||
{job.remote_file.replace(/\\/g, "/")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.tried.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-callout font-medium">
|
||||
Peers tried
|
||||
</h3>
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{job.tried.map((t, i) => (
|
||||
<li
|
||||
key={`${t.username}-${i}`}
|
||||
className="flex items-start gap-2 rounded-control border border-edge
|
||||
bg-[var(--surface-2)] px-2.5 py-2"
|
||||
>
|
||||
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-err" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{t.username}</div>
|
||||
<div className="text-caption text-dim">{t.reason}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{job.state === "downloading" && job.username && (
|
||||
<li
|
||||
className="flex items-start gap-2 rounded-control border border-accent-line
|
||||
bg-accent-soft px-2.5 py-2"
|
||||
>
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{job.username}</div>
|
||||
<div className="text-caption text-dim">downloading now</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StatePill({ state }: { state: Job["state"] }) {
|
||||
const map = {
|
||||
imported: { tone: "ok", icon: CheckCircle2, label: "In your library" },
|
||||
failed: { tone: "err", icon: XCircle, label: "Failed" },
|
||||
review: { tone: "accent", icon: CheckCircle2, label: "Waiting on you" },
|
||||
downloading: { tone: "warn", icon: Loader2, label: "Downloading" },
|
||||
searching: { tone: "warn", icon: Loader2, label: "Searching Soulseek" },
|
||||
queued: { tone: "neutral", icon: Loader2, label: "Queued" },
|
||||
} as const;
|
||||
const m = map[state];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Badge tone={m.tone}>
|
||||
<Icon className={`h-3 w-3 ${state === "imported" || state === "failed" ? "" : "animate-spin"}`} />
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Stop preview"
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
|
||||
text-ink cursor-pointer transition hover:border-accent-line
|
||||
hover:text-accent"
|
||||
>
|
||||
|
||||
+76
-83
@@ -2,9 +2,14 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { api, type Job } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox, ChevronRight } from "lucide-react";
|
||||
import { type Job } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { JobSheet } from "@/components/job-sheet";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
@@ -13,88 +18,89 @@ export type JobsSnapshot = {
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const PILL: Record<Job["state"], string> = {
|
||||
imported: "text-ok border-ok/40 bg-ok/10",
|
||||
failed: "text-err border-err/40 bg-err/10",
|
||||
// Held for review, not in flight and not finished — it needs a person, so
|
||||
// it reads as the accent rather than as another in-progress amber.
|
||||
review: "text-accent border-accent-line bg-accent-soft",
|
||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||
searching: "text-warn border-warn/40 bg-warn/10",
|
||||
queued: "text-warn border-warn/40 bg-warn/10",
|
||||
/**
|
||||
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||
* differed only by the word inside, so the three stages of a request looked
|
||||
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||
* now read as distinct.
|
||||
*/
|
||||
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||
queued: { tone: "neutral", label: "Queued" },
|
||||
searching: { tone: "accent", label: "Searching", live: true },
|
||||
downloading: { tone: "warn", label: "Downloading", live: true },
|
||||
review: { tone: "accent", label: "Needs review" },
|
||||
imported: { tone: "ok", label: "In library" },
|
||||
failed: { tone: "err", label: "Failed" },
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
const ok = useMotionOK();
|
||||
const [open, setOpen] = React.useState<string | null>(null);
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const { jobs, counts } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const held = counts.review ?? 0;
|
||||
const total = active + done;
|
||||
|
||||
const held = counts.review ?? 0;
|
||||
const stats: [string, number, string][] = [
|
||||
["In flight", active, "text-accent"],
|
||||
["Imported", done, "text-ok"],
|
||||
["Held", held, held > 0 ? "text-warn" : ""],
|
||||
["Failed", failed, "text-err"],
|
||||
["In library", done, "text-ok"],
|
||||
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
|
||||
];
|
||||
|
||||
// Kept in sync with the poll so the sheet updates live while it is open.
|
||||
const current = jobs.find((j) => j.id === open) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
{stats.map(([label, n, colour]) => (
|
||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||
<motion.b
|
||||
key={n}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn("block text-title leading-none nums", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
|
||||
<Card key={label} pad="sm">
|
||||
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||
{label}
|
||||
</span>
|
||||
</motion.div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 4 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||
>
|
||||
<motion.i
|
||||
className="block h-1 rounded-full bg-accent"
|
||||
animate={{ width: `${(done / total) * 100}%` }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
<Progress
|
||||
value={(done / total) * 100}
|
||||
className="mb-4"
|
||||
label="Requests completed"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Nothing requested yet"
|
||||
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
<AnimatePresence initial={false}>
|
||||
{jobs.map((j) => (
|
||||
<motion.div
|
||||
{jobs.map((j) => {
|
||||
const s = STATE[j.state] ?? STATE.queued;
|
||||
return (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
onClick={() => setOpen(j.id)}
|
||||
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
|
||||
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
|
||||
text-left transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -110,45 +116,32 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
||||
<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(" · ")}
|
||||
{[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>
|
||||
|
||||
{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") && (
|
||||
<Badge tone={s.tone} className="shrink-0">
|
||||
{s.live && (
|
||||
<motion.span
|
||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||
animate={{ opacity: [1, 0.25, 1] }}
|
||||
className="h-[5px] w-[5px] rounded-full bg-current"
|
||||
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{j.state}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Check, FileAudio, ShieldQuestion, X } from "lucide-react";
|
||||
import { api, bytes, type ReviewItem } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
}, [load]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!items) return <Skeletons n={3} />;
|
||||
if (!items) return <div className="flex flex-col gap-3">{[0,1,2].map((i)=>(<Skeleton key={i} className="h-[196px] rounded-panel" />))}</div>;
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
>
|
||||
<div
|
||||
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
||||
border border-edge bg-[var(--glass)] text-dim"
|
||||
border border-edge bg-[var(--surface)] text-dim"
|
||||
>
|
||||
<Check className="h-6 w-6" />
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ function ReviewCard({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 30 }}
|
||||
className="glass rounded-panel p-4 sm:p-5"
|
||||
className="card rounded-panel p-4 sm:p-5"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{item.cover ? (
|
||||
@@ -123,11 +123,11 @@ function ReviewCard({
|
||||
<img
|
||||
src={item.cover}
|
||||
alt=""
|
||||
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
|
||||
className="h-16 w-16 shrink-0 rounded-card border border-edge object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-card
|
||||
border border-edge bg-faint text-dim"
|
||||
>
|
||||
<FileAudio className="h-6 w-6" />
|
||||
@@ -145,7 +145,7 @@ function ReviewCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
|
||||
<div className="mt-3.5 rounded-card border border-edge bg-[var(--surface)] p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
|
||||
<ShieldQuestion className="h-3 w-3" /> what was found
|
||||
</div>
|
||||
|
||||
@@ -9,19 +9,36 @@ import { cn } from "@/lib/utils";
|
||||
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
||||
|
||||
export function SearchBar({
|
||||
initial,
|
||||
busy,
|
||||
onSearch,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
}: {
|
||||
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
||||
initial?: string;
|
||||
busy?: boolean;
|
||||
onSearch: (q: string) => void;
|
||||
onAlbum: (id: number) => void;
|
||||
onArtist: (id: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = React.useState("");
|
||||
const [q, setQ] = React.useState(initial ?? "");
|
||||
// Follows the URL on Back/Forward, but never fights what is being typed:
|
||||
// only a genuinely different incoming query replaces the field.
|
||||
const seeded = React.useRef(initial);
|
||||
React.useEffect(() => {
|
||||
if (initial !== undefined && initial !== seeded.current) {
|
||||
seeded.current = initial;
|
||||
typed.current = false;
|
||||
setQ(initial);
|
||||
setOpen(false);
|
||||
}
|
||||
}, [initial]);
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// A value seeded from the URL must not pop the suggestion list open over the
|
||||
// results that link was for. Only typing counts as asking for suggestions.
|
||||
const typed = React.useRef(false);
|
||||
const [sel, setSel] = React.useState(-1);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -76,7 +93,7 @@ export function SearchBar({
|
||||
];
|
||||
setItems(next);
|
||||
setSel(-1);
|
||||
setOpen(next.length > 0);
|
||||
setOpen(typed.current && next.length > 0);
|
||||
} catch {
|
||||
/* aborted or offline — leave the previous list alone */
|
||||
}
|
||||
@@ -129,9 +146,9 @@ export function SearchBar({
|
||||
<div className="relative z-50">
|
||||
<div
|
||||
className={cn(
|
||||
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-glass",
|
||||
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
||||
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-out",
|
||||
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||
)}
|
||||
>
|
||||
@@ -140,7 +157,10 @@ export function SearchBar({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
typed.current = true;
|
||||
setQ(e.target.value);
|
||||
}}
|
||||
onKeyDown={key}
|
||||
onFocus={() => items.length && setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||
@@ -161,7 +181,7 @@ export function SearchBar({
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
||||
glass-hi text-dim hover:text-ink cursor-pointer"
|
||||
card text-dim hover:text-ink cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{anonymous && (
|
||||
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||
<div className="card rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||
Authentication is turned off on this instance, so there is no account to
|
||||
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
|
||||
to <code className="font-mono text-[12px]">github</code> or{" "}
|
||||
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
||||
</div>
|
||||
)}
|
||||
<section className="glass rounded-panel p-5">
|
||||
<section className="card rounded-panel p-5">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-headline">Notifications</h2>
|
||||
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</p>
|
||||
|
||||
{!me.mail_configured && (
|
||||
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
|
||||
<div className="mb-4 rounded-card border border-warn/40 bg-[var(--surface)] p-3 text-callout text-warn">
|
||||
No SMTP server is configured on this instance, so nothing will actually be
|
||||
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
|
||||
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
|
||||
@@ -79,7 +79,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
|
||||
disabled={anonymous}
|
||||
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
|
||||
className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
||||
className="focus-ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
||||
text-body outline-none transition focus:border-accent-line
|
||||
disabled:opacity-45"
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
role="switch"
|
||||
aria-checked={notify}
|
||||
disabled={anonymous}
|
||||
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
|
||||
className="focus-ring mt-4 flex cursor-pointer items-center gap-3 rounded-card
|
||||
text-left disabled:cursor-default disabled:opacity-45"
|
||||
>
|
||||
<span
|
||||
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-panel p-5">
|
||||
<section className="card rounded-panel p-5">
|
||||
<h2 className="mb-3 text-headline">Account</h2>
|
||||
{anonymous ? (
|
||||
<p className="text-callout text-dim">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
ListMusic,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
Settings2,
|
||||
Gauge,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Tab } from "@/lib/nav";
|
||||
|
||||
export type NavItem = {
|
||||
tab: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: "Library" | "Activity" | "Manage";
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped, because a flat list of seven is a menu and a grouped list of seven
|
||||
* is a structure. The order is the order of use: you search, you watch what
|
||||
* you asked for, you administer occasionally.
|
||||
*/
|
||||
export const NAV: NavItem[] = [
|
||||
{ tab: "search", label: "Search", icon: Search, group: "Library" },
|
||||
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
|
||||
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
|
||||
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
|
||||
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
|
||||
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
|
||||
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
|
||||
];
|
||||
|
||||
/** The five that earn a slot on a phone's bottom bar. */
|
||||
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Music2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { NAV } from "./nav-items";
|
||||
|
||||
/**
|
||||
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
|
||||
* Settings. It replaces a horizontally scrolling row of pills, which is a
|
||||
* mobile-web affordance that had been left on the desktop layout too.
|
||||
*
|
||||
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
|
||||
* would eat a third of the window.
|
||||
*/
|
||||
export function Sidebar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
rail,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
rail: boolean;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const groups = ["Library", "Activity", "Manage"] as const;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className={cn(
|
||||
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
|
||||
"md:flex",
|
||||
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
|
||||
<Music2 className="h-4 w-4" />
|
||||
</span>
|
||||
{!rail && <span className="text-headline tracking-tight">muse</span>}
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const items = NAV.filter((n) => n.group === g && visible(n.tab));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g} className="mb-2 w-full">
|
||||
{!rail && (
|
||||
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
|
||||
{g}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<li key={tab}>
|
||||
<a
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
title={rail ? label : undefined}
|
||||
onClick={(e) => {
|
||||
// Left-click navigates in place; cmd/ctrl-click still
|
||||
// opens a real new tab, because these are real links.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex items-center rounded-control text-callout",
|
||||
"font-medium transition-colors duration-150",
|
||||
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
|
||||
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<motion.span
|
||||
layoutId="nav-selection"
|
||||
transition={ok ? spring : { duration: 0 }}
|
||||
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative h-4 w-4 shrink-0" />
|
||||
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-pill text-caption font-bold nums",
|
||||
rail
|
||||
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
|
||||
: "relative px-1.5",
|
||||
tab === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertTriangle, WifiOff } from "lucide-react";
|
||||
import { api, type Health } from "@/lib/api";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* System health, for everyone.
|
||||
*
|
||||
* This check already existed but lived inside the admin panel, so a regular
|
||||
* user whose every request was failing saw nothing but a growing pile of red
|
||||
* pills. "Reachable but not logged in" is the important case: the container is
|
||||
* up and healthy, so no other signal reports a problem, yet every search
|
||||
* returns nothing.
|
||||
*/
|
||||
export function StatusBanner() {
|
||||
const [h, setH] = React.useState<Health | null>(null);
|
||||
const ok = useMotionOK();
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api.health();
|
||||
if (live) setH(r);
|
||||
} catch {
|
||||
/* a 401 here just means the poll outlived the session */
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const t = setInterval(poll, 20000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const problem = !h
|
||||
? null
|
||||
: h.slskd !== 200
|
||||
? {
|
||||
icon: WifiOff,
|
||||
text: "Can't reach slskd — nothing will download until it's back.",
|
||||
}
|
||||
: h.logged_in === false
|
||||
? {
|
||||
icon: AlertTriangle,
|
||||
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
|
||||
}
|
||||
: h.library_writable === false
|
||||
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{problem && (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={ok ? { height: 0, opacity: 0 } : false}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
|
||||
<problem.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="clamp1">{problem.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { NAV, MOBILE } from "./nav-items";
|
||||
|
||||
/**
|
||||
* The phone layout. A bottom bar is what a native app does, and it puts the
|
||||
* targets under the thumb instead of at the top of a tall screen. Respects the
|
||||
* home-indicator inset, which the previous layout did not.
|
||||
*/
|
||||
export function TabBar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
|
||||
pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
>
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<a
|
||||
key={tab}
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
|
||||
"text-caption font-medium transition-colors",
|
||||
on ? "text-accent" : "text-mute",
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="h-5 w-5" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
|
||||
"font-bold leading-[15px] nums",
|
||||
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Me } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Sticky chrome over scrolling content: search on the left, identity on the
|
||||
* right. Everything that used to be hidden below `sm:` — the quota chip, the
|
||||
* display name — now degrades into the overflow rather than vanishing, since
|
||||
* "how many requests do I have left" matters most on the device you are
|
||||
* actually queueing from.
|
||||
*/
|
||||
export function Toolbar({
|
||||
me,
|
||||
quotaLeft,
|
||||
children,
|
||||
}: {
|
||||
me: Me | null;
|
||||
quotaLeft: number | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="material sticky top-0 z-30 border-b border-edge">
|
||||
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
|
||||
{me?.user && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{quotaLeft !== null && (
|
||||
<Badge
|
||||
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className="nums"
|
||||
>
|
||||
{quotaLeft} left
|
||||
</Badge>
|
||||
)}
|
||||
{me.role && me.role !== "user" && (
|
||||
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
|
||||
{me.role}
|
||||
</Badge>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-7 w-7 rounded-full border border-edge object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
title="Log out"
|
||||
aria-label="Log out"
|
||||
className={cn(
|
||||
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
|
||||
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
|
||||
export function Skeletons({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="shimmer h-[68px] rounded-glass border border-edge"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+69
-15
@@ -2,43 +2,97 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
|
||||
type Toast = { id: number; msg: string; err?: boolean };
|
||||
type Ctx = (msg: string, err?: boolean) => void;
|
||||
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
|
||||
type Action = { label: string; run: () => void };
|
||||
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
|
||||
|
||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||
export const useToast = () => React.useContext(ToastCtx);
|
||||
|
||||
/** An error you cannot read in time is the same as no error at all. */
|
||||
const DWELL_OK = 3400;
|
||||
const DWELL_ERR = 7000;
|
||||
/** Beyond this the stack is a wall, and the oldest is the least relevant. */
|
||||
const MAX = 3;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = React.useState<Toast[]>([]);
|
||||
const ok = useMotionOK();
|
||||
|
||||
const push = React.useCallback<Ctx>((msg, err) => {
|
||||
const drop = React.useCallback(
|
||||
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
|
||||
[],
|
||||
);
|
||||
|
||||
const push = React.useCallback<Ctx>(
|
||||
(msg, err, action) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((x) => [...x, { id, msg, err }]);
|
||||
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
||||
}, []);
|
||||
setItems((x) => [...x, { id, msg, err, action }].slice(-MAX));
|
||||
setTimeout(() => drop(id), err ? DWELL_ERR : DWELL_OK);
|
||||
},
|
||||
[drop],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
|
||||
{/*
|
||||
aria-live so a screen reader hears "Queued" or a failure at all — these
|
||||
were previously silent, which made the only confirmation that a request
|
||||
had been accepted a purely visual one.
|
||||
*/}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-24 z-[110] flex flex-col
|
||||
items-center gap-2 px-4 md:bottom-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{items.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
className={cn(
|
||||
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
|
||||
"max-w-[calc(100vw-32px)]",
|
||||
t.err && "border-err/50 text-err",
|
||||
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
|
||||
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
|
||||
t.err && "border-err/45",
|
||||
)}
|
||||
>
|
||||
{t.msg}
|
||||
{t.err ? (
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-err" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1", t.err && "text-err")}>{t.msg}</span>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.run();
|
||||
drop(t.id);
|
||||
}}
|
||||
className="focus-ring shrink-0 rounded-pill px-2.5 py-0.5 text-callout
|
||||
font-semibold text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => drop(t.id)}
|
||||
aria-label="Dismiss"
|
||||
className="focus-ring grid h-6 w-6 shrink-0 place-items-center rounded-full
|
||||
text-mute transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -73,8 +73,8 @@ export function TrackRow({
|
||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||
whileHover={{ y: -1 }}
|
||||
className={cn(
|
||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
||||
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
|
||||
isPlaying && "border-accent-line bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badge = cva(
|
||||
"inline-flex items-center gap-1 rounded-pill border px-2 py-0.5 " +
|
||||
"text-caption font-medium whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: "border-edge text-dim",
|
||||
accent: "border-accent-line bg-accent-soft text-accent",
|
||||
ok: "border-ok/40 bg-ok/10 text-ok",
|
||||
warn: "border-warn/40 bg-warn/10 text-warn",
|
||||
err: "border-err/40 bg-err/10 text-err",
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: "neutral" },
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
tone,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badge>) {
|
||||
return <span className={cn(badge({ tone }), className)} {...props} />;
|
||||
}
|
||||
@@ -6,30 +6,36 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const button = cva(
|
||||
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
||||
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
||||
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
||||
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
|
||||
"select-none whitespace-nowrap focus-ring " +
|
||||
"transition-[background,border-color,color,transform,box-shadow] " +
|
||||
"duration-150 ease-out active:scale-[0.97] " +
|
||||
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
|
||||
"disabled:active:scale-100",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Solid accent. The label is near-black because the accent is clamped
|
||||
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
||||
// to stay in step or this button becomes unreadable on some albums.
|
||||
primary:
|
||||
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
|
||||
"active:scale-[0.97] active:brightness-95",
|
||||
ghost:
|
||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
||||
// The accent is fixed now, not sampled from artwork, so a solid button
|
||||
// has a known contrast ratio and can carry a plain white label. It
|
||||
// used to be near-black text on a colour taken from the album cover,
|
||||
// which only worked because colour.ts clamped the luminance.
|
||||
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
|
||||
secondary:
|
||||
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
|
||||
"hover:bg-[var(--surface-2)]",
|
||||
outline:
|
||||
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
||||
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
||||
"bg-transparent border border-edge-hi text-ink " +
|
||||
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
|
||||
quiet: "bg-transparent text-dim hover:text-ink hover:bg-[var(--faint)]",
|
||||
danger: "bg-transparent border border-err/45 text-err hover:bg-err/10",
|
||||
},
|
||||
size: {
|
||||
sm: "text-caption px-3 py-1.5 rounded-pill",
|
||||
md: "text-callout px-4.5 py-2.5 rounded-control",
|
||||
pill: "text-callout px-4 py-2 rounded-pill",
|
||||
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
|
||||
sm: "text-callout px-3 py-1.5 rounded-control",
|
||||
md: "text-callout px-4 py-2 rounded-control",
|
||||
lg: "text-body px-5 py-2.5 rounded-control font-semibold",
|
||||
icon: "h-8 w-8 rounded-control",
|
||||
pill: "text-callout px-4 py-1.5 rounded-pill",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "md" },
|
||||
@@ -45,9 +51,7 @@ export interface ButtonProps
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
||||
);
|
||||
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The content surface. `"card rounded-panel p-5"` was copy-pasted across eight
|
||||
* files with the padding drifting between p-3, p-3.5, p-4 and p-5; this fixes
|
||||
* the scale in one place.
|
||||
*/
|
||||
export function Card({
|
||||
className,
|
||||
pad = "md",
|
||||
interactive,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
pad?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"card rounded-panel",
|
||||
{ none: "", sm: "p-3", md: "p-4", lg: "p-5 sm:p-6" }[pad],
|
||||
interactive && "lift cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mb-3 flex items-center justify-between gap-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("text-headline", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardNote({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-callout text-dim", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Seven screens rendered a bare centred string for "nothing here" and two had
|
||||
* a real designed state. This is the designed one, made reusable.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
tone = "neutral",
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
tone?: "neutral" | "err";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center px-6 py-14 text-center", className)}>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 grid h-12 w-12 place-items-center rounded-panel border",
|
||||
tone === "err"
|
||||
? "border-err/35 bg-err/10 text-err"
|
||||
: "border-edge bg-[var(--surface-2)] text-mute",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className={cn("text-headline", tone === "err" && "text-err")}>{title}</h3>
|
||||
{body && <p className="mt-1.5 max-w-[42ch] text-callout text-dim">{body}</p>}
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Shared shell for every text control, so focus looks the same everywhere. */
|
||||
export const controlBase =
|
||||
"w-full rounded-control border border-edge bg-[var(--surface-2)] px-3 py-2 " +
|
||||
"text-body text-ink outline-none placeholder:text-mute " +
|
||||
"transition-[border-color,box-shadow,background] duration-150 ease-out " +
|
||||
"focus:border-accent-line focus:bg-[var(--surface)] " +
|
||||
"focus:shadow-[0_0_0_3px_var(--accent-soft)] " +
|
||||
"disabled:opacity-45 disabled:cursor-not-allowed";
|
||||
|
||||
export const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cn(controlBase, className)} {...props} />
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea ref={ref} className={cn(controlBase, "resize-y leading-relaxed", className)} {...props} />
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
/**
|
||||
* Options inherit the surface token rather than a hardcoded `#12141c`, which
|
||||
* previously rendered dark dropdown items on a white page in light mode.
|
||||
*/
|
||||
export const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<select ref={ref} className={cn(controlBase, "cursor-pointer pr-8", className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className="text-callout font-medium text-dim">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-caption text-mute">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One progress bar. There were six hand-rolled ones with four different
|
||||
* heights, while @radix-ui/react-progress sat installed and unimported.
|
||||
*/
|
||||
export function Progress({
|
||||
value,
|
||||
tone = "accent",
|
||||
size = "md",
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number; // 0..100
|
||||
tone?: "accent" | "ok" | "warn" | "err" | "art";
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-pill bg-[var(--surface-3)]",
|
||||
size === "sm" ? "h-1" : "h-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-pill transition-[width] duration-500 ease-out",
|
||||
{ accent: "bg-accent", ok: "bg-ok", warn: "bg-warn", err: "bg-err", art: "bg-art" }[tone],
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* A real modal, on Radix, which was already a dependency and never imported.
|
||||
* That matters beyond tidiness: it brings focus trapping, restore-on-close,
|
||||
* Escape, scroll locking and aria wiring that hand-rolled inline confirms
|
||||
* (the previous pattern) do not have.
|
||||
*
|
||||
* Presents as a bottom sheet on phones and a centred panel from `sm:` up.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
|
||||
// Radix locks scrolling by injecting a <style> element, which this app's CSP
|
||||
// (style-src 'self', no 'unsafe-inline') blocks outright — the console says
|
||||
// so every time a sheet opens. Setting the property through the CSSOM is not
|
||||
// markup, so it is allowed, and it is the half that actually matters:
|
||||
// without it the page behind the sheet scrolls under your finger on a phone.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: ok ? 0.18 : 0 }}
|
||||
className="fixed inset-0 z-[100] bg-[var(--scrim)] backdrop-blur-[2px]"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content asChild forceMount>
|
||||
<motion.div
|
||||
initial={ok ? { opacity: 0, y: 24, scale: 0.98 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={ok ? { opacity: 0, y: 16, scale: 0.98 } : { opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className={cn(
|
||||
"sheet fixed z-[101] flex flex-col overflow-hidden",
|
||||
"inset-x-0 bottom-0 max-h-[86vh] rounded-t-sheet rounded-b-none",
|
||||
"sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2",
|
||||
"sm:max-h-[82vh] sm:w-[min(560px,calc(100vw-32px))]",
|
||||
"sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-sheet",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-edge px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title className="text-headline">{title}</Dialog.Title>
|
||||
{description && (
|
||||
<Dialog.Description className="mt-0.5 text-callout text-dim">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center
|
||||
rounded-control text-dim transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shape-aware. There was one fixed 68px row used for the admin dashboard, the
|
||||
* discover grid and the review queue alike, so the loading state never
|
||||
* resembled what actually arrived.
|
||||
*/
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("shimmer rounded-card border border-edge", className)} />;
|
||||
}
|
||||
|
||||
export function SkeletonRows({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-row">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[64px]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCards({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[84px] rounded-panel" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ n = 8 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full rounded-panel" />
|
||||
<Skeleton className="h-3 w-3/4 rounded-pill border-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** CSS-driven, so the reduced-motion block in globals.css actually stops it. */
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 shrink-0 rounded-full border-2 border-current",
|
||||
"border-r-transparent [animation:spin_0.7s_linear_infinite]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { item, useMotionOK } from "@/lib/motion";
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
|
||||
/**
|
||||
* The first screen.
|
||||
*
|
||||
* It used to be a floating disc, a heading, and five hardcoded example chips —
|
||||
* a decorated placeholder that said nothing about this install. Leading with
|
||||
* the artwork of what has actually been imported makes the empty state a
|
||||
* library view instead, and falls back to the examples only when there is
|
||||
* genuinely nothing to show.
|
||||
*/
|
||||
export function Welcome({
|
||||
onPick,
|
||||
recent,
|
||||
}: {
|
||||
onPick: (q: string) => void;
|
||||
recent: Job[];
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const seen = new Set<string>();
|
||||
const imported = recent
|
||||
.filter((j) => j.state === "imported" && j.cover)
|
||||
.filter((j) => {
|
||||
const k = `${j.artist}|${j.album ?? j.title}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="mb-8 max-w-[52ch] pt-6">
|
||||
<h1 className="text-hero">Find anything.</h1>
|
||||
<p className="mt-2.5 text-body text-dim">
|
||||
Search a song, preview it, and press Get. It arrives in your library correctly
|
||||
tagged — right artist, right album, right track number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{imported.length > 0 ? (
|
||||
<section aria-labelledby="recent-h">
|
||||
<h2 id="recent-h" className="mb-3 text-headline">
|
||||
Recently added
|
||||
</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-4">
|
||||
{imported.map((j, i) => (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(`${j.artist} ${j.title}`)}
|
||||
className="focus-ring group text-left"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={j.cover!}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="lift mb-2 aspect-square w-full rounded-panel border border-edge
|
||||
object-cover shadow-[var(--e1)]"
|
||||
/>
|
||||
<div className="text-callout font-medium clamp1">{j.title}</div>
|
||||
<div className="text-caption text-dim clamp1">{j.artist}</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="try-h">
|
||||
<h2 id="try-h" className="mb-3 flex items-center gap-2 text-headline">
|
||||
<Search className="h-4 w-4 text-mute" /> Try one of these
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLES.map((e, i) => (
|
||||
<motion.button
|
||||
key={e}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(e)}
|
||||
className="focus-ring rounded-pill border border-edge bg-[var(--surface)] px-3.5
|
||||
py-1.5 text-callout text-dim transition-colors hover:border-accent-line
|
||||
hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
{e}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,15 @@ export type Job = {
|
||||
duration: number | null;
|
||||
requested_by: string | null;
|
||||
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 };
|
||||
@@ -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);
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
slskd?: number | string;
|
||||
logged_in?: boolean;
|
||||
shared_files?: number | null;
|
||||
library_writable?: boolean;
|
||||
downloads_readable?: boolean;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
me: () => get<Me>("/api/me"),
|
||||
health: () => get<Health>("/api/health"),
|
||||
search: (q: string) =>
|
||||
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||
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