Compare commits
9
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/
|
.venv/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dev/
|
dev/
|
||||||
|
|
||||||
|
# Local diagnostics: contain the live instance's Soulseek username,
|
||||||
|
# server paths and peer names. Not for a public repo.
|
||||||
|
FINDINGS.md
|
||||||
|
SERVER_AUDIT.md
|
||||||
@@ -148,8 +148,40 @@ automatically. Every job shows what it *resolved to*, so a bad auto-pick is
|
|||||||
visible in the list rather than quietly landing the wrong song. Lines that
|
visible in the list rather than quietly landing the wrong song. Lines that
|
||||||
match nothing are reported back as skipped instead of failing the batch.
|
match nothing are reported back as skipped instead of failing the batch.
|
||||||
|
|
||||||
Failed jobs get a **Retry** button. Most Soulseek failures are the peer going
|
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
|
||||||
offline, not a bad request, so retrying later usually just works.
|
a request that loses its peer falls through to the next candidate on its own.
|
||||||
|
See "When a peer says no" below.
|
||||||
|
|
||||||
|
## Shared playlist links
|
||||||
|
|
||||||
|
Paste a Spotify, Apple Music or Deezer playlist link and muse resolves it,
|
||||||
|
matches every track the same way a pasted line is matched, and writes an `.m3u`
|
||||||
|
into the library that Navidrome picks up on its next scan — within seconds, in
|
||||||
|
practice. The playlist is written **per import**, not at the end, so it appears
|
||||||
|
with the first track that lands and grows as the rest arrive. Tracks that never
|
||||||
|
resolve are reported rather than silently dropped, and a track rejected in
|
||||||
|
review disappears from the playlist on the next rewrite.
|
||||||
|
|
||||||
|
Resolving is deliberately separate from queueing. A shared link is opaque — you
|
||||||
|
cannot tell twelve tracks from four hundred by looking at it — so the track list
|
||||||
|
and count are shown before anything is committed against a quota.
|
||||||
|
|
||||||
|
**Two of the three sources are scrapes, and this is worth understanding before
|
||||||
|
you rely on it.** Deezer has a real public API. Spotify and Apple Music have no
|
||||||
|
free way to read a public playlist — Apple's MusicKit requires a paid developer
|
||||||
|
account — so muse reads the public web page and parses the JSON blob the page
|
||||||
|
already embeds for its own player. No key, no OAuth, no account. It also means
|
||||||
|
those two will break the day either company reshuffles their frontend. The
|
||||||
|
resolvers therefore fail loudly and specifically ("Spotify's embed page no
|
||||||
|
longer carries `__NEXT_DATA__` — the resolver in app.py needs updating") rather
|
||||||
|
than quietly returning nothing, because an empty playlist and a changed page
|
||||||
|
must not look the same to whoever has to fix it.
|
||||||
|
|
||||||
|
One detail that is easy to get wrong: the `.m3u` has to use **Navidrome's**
|
||||||
|
mount path, not muse's. Both containers see the same directory, muse at
|
||||||
|
`/data/media/music` and Navidrome at `/music`, and a playlist written with the
|
||||||
|
former resolves to nothing on the other side. `MUSE_PLAYLIST_PREFIX` is that
|
||||||
|
path.
|
||||||
|
|
||||||
### Everything goes through a bounded queue
|
### Everything goes through a bounded queue
|
||||||
|
|
||||||
@@ -179,10 +211,115 @@ is needed to catch `Come As You Are (Live)`, but substring matching would also
|
|||||||
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
|
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
|
||||||
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
|
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
|
||||||
|
|
||||||
**Picking the file (Soulseek).** Format preference first, then whether the peer
|
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
|
||||||
can actually serve you (free upload slot, short queue), then filename
|
first — a free upload slot and a short queue — then format preference, then
|
||||||
plausibility, then bitrate. A marginally better file behind 200 queued users
|
filename plausibility, then bitrate. A marginally better file behind 200 queued
|
||||||
loses to a good-enough one available now.
|
users loses to a good-enough one available now.
|
||||||
|
|
||||||
|
That order is deliberate and was once the other way round, which was a bug
|
||||||
|
worth naming: format outranked availability, so a FLAC behind a five-thousand
|
||||||
|
deep queue always beat an MP3 that was free to download immediately. Whether
|
||||||
|
the peer can serve you is the single most predictive signal for whether the
|
||||||
|
job ever finishes, so nothing outranks it.
|
||||||
|
|
||||||
|
## Searching for a name the file doesn't use
|
||||||
|
|
||||||
|
Two things go wrong between "what Deezer calls this track" and "what a stranger
|
||||||
|
named the file", and both used to end the request.
|
||||||
|
|
||||||
|
**The Soulseek server filters some artist names.** This is measurable: on a
|
||||||
|
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
|
||||||
|
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
|
||||||
|
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
|
||||||
|
Ocean. Sending one query of `artist title` — which is all muse used to do —
|
||||||
|
means every request for a filtered artist fails reporting "searched 0 files",
|
||||||
|
indistinguishable from a track nobody shares.
|
||||||
|
|
||||||
|
So muse tries a **ladder** of queries, loosest last, and stops at the first
|
||||||
|
rung that yields enough candidates:
|
||||||
|
|
||||||
|
```
|
||||||
|
Michael Jackson Billie Jean 0 responses
|
||||||
|
Billie Jean 251 responses, 1083 files
|
||||||
|
```
|
||||||
|
|
||||||
|
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
|
||||||
|
a `feat.` credit, a leading article, everything after a `/` in a split title,
|
||||||
|
and finally the artist itself. Since most requests are answered by the first
|
||||||
|
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
|
||||||
|
caps how far it will go.
|
||||||
|
|
||||||
|
Dropping the artist from the *query* does not mean ignoring it — it makes the
|
||||||
|
artist matter more. A bare-title search stops returning "this song from various
|
||||||
|
peers" and starts returning *every song anyone ever called Chicago*: The Fall,
|
||||||
|
Groove Armada, Sufjan Stevens. The artist is then the only thing separating the
|
||||||
|
right recording from a different song entirely, so it is a **gate**: candidates
|
||||||
|
whose path names the artist are preferred outright, and ones that do not are
|
||||||
|
used only when nothing else is left.
|
||||||
|
|
||||||
|
That has to be a gate rather than a weighting, and the reason is worth stating.
|
||||||
|
A perfect title with no artist scores 0.75, comfortably above `MUSE_MIN_MATCH`,
|
||||||
|
so as a mere contribution it loses to availability — and an idle peer holding
|
||||||
|
the wrong song beats a busy one holding the right song. That is not
|
||||||
|
hypothetical either: it filed The Fall's "Chicago, Now!" as Michael Jackson.
|
||||||
|
The same principle the confidence score is built on applies here: a fast peer
|
||||||
|
must not be able to promote a wrong match.
|
||||||
|
|
||||||
|
The artist is 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.
|
||||||
|
|
||||||
|
**Unreachable peers are the norm, not the exception.** slskd answers 500 when it
|
||||||
|
cannot open a connection to a peer within its five-second window, and on a live
|
||||||
|
instance that is common: one request walked four such peers before the fifth
|
||||||
|
served it. This is why the ladder is worth having and why `MUSE_MAX_ATTEMPTS`
|
||||||
|
defaults to the full `MUSE_CANDIDATES` rather than a smaller number — a cap
|
||||||
|
below the ladder size throws away candidates that would have worked.
|
||||||
|
|
||||||
|
Attempts are spaced by `MUSE_ATTEMPT_BACKOFF`. That is politeness rather than a
|
||||||
|
fix for anything measured: each enqueue holds a peer connection slot for up to
|
||||||
|
five seconds, and there is no reason to ask for eight of them in immediate
|
||||||
|
succession. Separately, `MUSE_SYSTEMIC_LIMIT` consecutive *connection* errors —
|
||||||
|
meaning slskd itself is unreachable, not a peer — stop the walk and **keep the
|
||||||
|
untried candidates on the row**, so retrying resumes the ladder rather than
|
||||||
|
paying for a search whose answer has not changed. A 500 is deliberately not
|
||||||
|
counted as systemic: it describes one peer, and treating it as systemic would
|
||||||
|
have abandoned the request that succeeded on its fifth attempt.
|
||||||
|
|
||||||
## Review: when muse isn't sure
|
## Review: when muse isn't sure
|
||||||
|
|
||||||
@@ -202,6 +339,16 @@ filename alone:
|
|||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
|
**Length is checked against the audio, not the name.** Every textual signal can
|
||||||
|
agree and the file still be the wrong song: right title, right folder, right
|
||||||
|
album — and six minutes of a track that runs four. So once the download
|
||||||
|
finishes, the file's real duration is compared against what Deezer said, and a
|
||||||
|
drift past `MUSE_LENGTH_TOLERANCE` multiplies confidence down and says so in
|
||||||
|
words: *"runs 5:59 but the track is 4:05 (47% out), so this is probably a
|
||||||
|
different recording"*. This is applied on import rather than inside
|
||||||
|
`confidence()` for the obvious reason that the file does not exist yet when the
|
||||||
|
candidate is picked.
|
||||||
|
|
||||||
- **≥ 0.95** — filed automatically. Nothing to do.
|
- **≥ 0.95** — filed automatically. Nothing to do.
|
||||||
- **0.70 – 0.95** — held in **Review** with the reasons shown.
|
- **0.70 – 0.95** — held in **Review** with the reasons shown.
|
||||||
- **< 0.70** — also held, but it will almost certainly be rejected.
|
- **< 0.70** — also held, but it will almost certainly be rejected.
|
||||||
@@ -470,9 +617,21 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
|
|||||||
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
|
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
|
||||||
| `MUSE_DB` | `/config/muse.db` | job history |
|
| `MUSE_DB` | `/config/muse.db` | job history |
|
||||||
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
||||||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
|
| `MUSE_MIN_MATCH` | `0.45` | filename-to-track similarity floor |
|
||||||
|
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
|
||||||
|
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
|
||||||
|
| `MUSE_MAX_ATTEMPTS` | `MUSE_CANDIDATES` | peers tried before a request is given up on |
|
||||||
|
| `MUSE_ATTEMPT_BACKOFF` | `4` | seconds between candidate attempts |
|
||||||
|
| `MUSE_SYSTEMIC_LIMIT` | `2` | consecutive connection failures before stopping |
|
||||||
|
| `MUSE_STALL_MINUTES` | `20` | a transfer moving no bytes for this long is abandoned |
|
||||||
|
| `MUSE_MAX_QUERIES` | `4` | rungs of the search ladder to try |
|
||||||
|
| `MUSE_MIN_CANDIDATES` | `3` | below this, escalate to the next query |
|
||||||
|
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
|
||||||
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
|
| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking |
|
||||||
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
|
| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong |
|
||||||
|
| `MUSE_LENGTH_TOLERANCE` | `0.2` | duration drift before a file is called a different recording |
|
||||||
|
| `MUSE_MAX_PLAYLIST_TRACKS` | `500` | cap on one playlist link |
|
||||||
|
| `MUSE_PLAYLIST_PREFIX` | `/music` | the library path *Navidrome* sees, for `.m3u` lines |
|
||||||
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
| `MUSE_WORKERS` | `3` | concurrent Soulseek searches |
|
||||||
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
|
| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above |
|
||||||
| `MUSE_SMTP_PORT` | `587` | |
|
| `MUSE_SMTP_PORT` | `587` | |
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""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
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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("\nthe artist gate")
|
||||||
|
check("artist named in the folder is confirmed",
|
||||||
|
app.artist_confirmed(r"m\Michael Jackson\Xscape\02 Chicago.flac",
|
||||||
|
"Michael Jackson"))
|
||||||
|
check("a different band's song with the same title is not",
|
||||||
|
not app.artist_confirmed(
|
||||||
|
r"@@jwbvv\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac",
|
||||||
|
"Michael Jackson"))
|
||||||
|
check("out-of-order naming still counts",
|
||||||
|
app.artist_confirmed(r"x\Jackson, Michael - Chicago.flac",
|
||||||
|
"Michael Jackson"))
|
||||||
|
|
||||||
|
# The real regression: bare-title search returns every song called
|
||||||
|
# "Chicago", and an available stranger used to outrank a queued match.
|
||||||
|
gate = FakeSlskd(per_query={"Chicago": [
|
||||||
|
peer("stranger", r"@@x\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac",
|
||||||
|
slot=True, queue=0),
|
||||||
|
peer("correct", r"m\Michael Jackson\XSCAPE\02 - Chicago.flac",
|
||||||
|
slot=False, queue=300),
|
||||||
|
]})
|
||||||
|
app.slskd_client = gate
|
||||||
|
make_job("j6", "Michael Jackson", "Chicago")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j6", "Michael Jackson", "Chicago"))
|
||||||
|
r = job_row("j6")
|
||||||
|
check("the right artist wins even from a peer with 300 queued",
|
||||||
|
r["username"] == "correct", f"picked {r['username']}")
|
||||||
|
check("and the wrong-artist file is dropped, not merely ranked lower",
|
||||||
|
len(json.loads(r["candidates"] or "[]")) == 0, r["candidates"])
|
||||||
|
|
||||||
|
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"])
|
||||||
|
# A row carried over from before the ladder has attempts=0 but a real
|
||||||
|
# failure recorded, which must not render as "tried 0 peers: 1x ...".
|
||||||
|
check("a migrated row counts its evidence, not its counter",
|
||||||
|
app.exhausted_detail(
|
||||||
|
[{"username": "u1", "reason": "no bytes in 20m"}], 0)
|
||||||
|
== "tried 1 peer: 1x no bytes in 20m",
|
||||||
|
app.exhausted_detail([{"username": "u1", "reason": "no bytes in 20m"}], 0))
|
||||||
|
|
||||||
|
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
|
||||||
|
# The cap defaults to the ladder size, so lower it here or this only ever
|
||||||
|
# proves the list ran out -- which is a different branch.
|
||||||
|
cap, app.MAX_ATTEMPTS = app.MAX_ATTEMPTS, 3
|
||||||
|
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"] == 3,
|
||||||
|
f"state={r['state']} attempts={r['attempts']}")
|
||||||
|
check("and leaves the untried remainder alone",
|
||||||
|
len(json.loads(r["candidates"] or "[]")) > 0, r["candidates"])
|
||||||
|
app.MAX_ATTEMPTS = cap
|
||||||
|
check("unreachable peers are reported as such",
|
||||||
|
"could not reach peer" in (r["tried"] or ""), r["detail"])
|
||||||
|
|
||||||
|
print("\nsystemic failures do not burn the ladder")
|
||||||
|
# slskd timing out says nothing about any particular file, so walking the
|
||||||
|
# whole list to learn it once is waste -- and it is what took a real
|
||||||
|
# request down: five enqueues in twenty-five seconds during one slskd stall.
|
||||||
|
fake4 = FakeSlskd(per_query={"Chicago": [
|
||||||
|
peer(f"p{i}", r"x\Michael Jackson\Xscape\Chicago.flac") for i in range(8)]})
|
||||||
|
|
||||||
|
class Timeouts(FakeSlskd):
|
||||||
|
async def post(self, url, json=None):
|
||||||
|
if url != "/api/v0/searches":
|
||||||
|
raise RuntimeError("The wait timed out after 5000 milliseconds")
|
||||||
|
return await FakeSlskd.post(self, url, json=json)
|
||||||
|
|
||||||
|
t = Timeouts(per_query=fake4.per_query)
|
||||||
|
app.slskd_client = t
|
||||||
|
make_job("j5", "Michael Jackson", "Chicago")
|
||||||
|
asyncio.run(app.pick_and_enqueue("j5", "Michael Jackson", "Chicago"))
|
||||||
|
r = job_row("j5")
|
||||||
|
check("stops after SYSTEMIC_LIMIT instead of walking the whole list",
|
||||||
|
r["attempts"] == app.SYSTEMIC_LIMIT, r["attempts"])
|
||||||
|
left = json.loads(r["candidates"] or "[]")
|
||||||
|
check("untried candidates are preserved for a retry", len(left) > 0, len(left))
|
||||||
|
check("the message says a retry will resume",
|
||||||
|
"retry resumes" in (r["detail"] 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("\nlength cross-check")
|
||||||
|
# The signal a filename cannot fake. Every textual cue agreed on the file
|
||||||
|
# that got filed as Michael Jackson; only the running time disagreed.
|
||||||
|
class FakeInfo:
|
||||||
|
def __init__(self, n): self.length = n
|
||||||
|
|
||||||
|
class FakeAudio:
|
||||||
|
def __init__(self, n): self.info = FakeInfo(n)
|
||||||
|
|
||||||
|
real_file = mutagen_file = app.mutagen.File
|
||||||
|
try:
|
||||||
|
app.mutagen.File = lambda p: FakeAudio(359.0) # The Fall
|
||||||
|
got = app.length_check(Path("x.flac"), 245 * 1000) # Michael Jackson
|
||||||
|
check("a 47% length disagreement is caught", got is not None, got)
|
||||||
|
check("and it drops the file below the review floor",
|
||||||
|
got and round(0.75 * got[0], 3) < app.REVIEW_CONFIDENCE,
|
||||||
|
got and round(0.75 * got[0], 3))
|
||||||
|
check("the reason names both lengths",
|
||||||
|
got and "5:59" in got[1] and "4:05" in got[1], got and got[1])
|
||||||
|
|
||||||
|
app.mutagen.File = lambda p: FakeAudio(247.0)
|
||||||
|
check("a two-second drift is not flagged",
|
||||||
|
app.length_check(Path("x.flac"), 245 * 1000) is None)
|
||||||
|
|
||||||
|
app.mutagen.File = lambda p: FakeAudio(359.0)
|
||||||
|
check("nothing to compare against is not a failure",
|
||||||
|
app.length_check(Path("x.flac"), None) is None)
|
||||||
|
finally:
|
||||||
|
app.mutagen.File = mutagen_file
|
||||||
|
|
||||||
|
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")))
|
||||||
|
# A row written before started_at existed still has to age out, or the
|
||||||
|
# jobs that have been stuck longest are the only ones exempt from the check.
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("UPDATE jobs SET started_at=NULL, created_at=? WHERE id=?",
|
||||||
|
((datetime.now(timezone.utc) - timedelta(days=2))
|
||||||
|
.isoformat(sep=" ")[:19], "j4"))
|
||||||
|
check("a pre-upgrade row falls back to created_at",
|
||||||
|
app.stalled_for(job_row("j4")) > app.STALL_MINUTES,
|
||||||
|
app.stalled_for(job_row("j4")))
|
||||||
|
|
||||||
|
print(f"\n{ok} passed, {fail} failed")
|
||||||
|
return 1 if fail else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Playlist import: link resolution, ordering, and the m3u Navidrome reads.
|
||||||
|
|
||||||
|
The resolvers are exercised against synthetic payloads rather than the live
|
||||||
|
sites. Two reasons: a test that fails when Spotify has a bad afternoon is not
|
||||||
|
testing this code, and the failure that actually matters here is the *shape*
|
||||||
|
changing -- which is precisely what a fixture can assert and a live fetch
|
||||||
|
cannot.
|
||||||
|
|
||||||
|
Run standalone: python tests/test_playlist.py
|
||||||
|
"""
|
||||||
|
import asyncio, json, os, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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/playlist.db")
|
||||||
|
os.environ.setdefault("MUSE_LIBRARY_DIR", "dev/pl-library")
|
||||||
|
|
||||||
|
for f in ("dev/playlist.db", "dev/playlist.db-wal", "dev/playlist.db-shm"):
|
||||||
|
if os.path.exists(f):
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
import app # noqa: E402
|
||||||
|
|
||||||
|
app.init_db()
|
||||||
|
app.LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
SPOTIFY_PAGE = (
|
||||||
|
'<html><script id="__NEXT_DATA__" type="application/json">'
|
||||||
|
+ json.dumps({"props": {"pageProps": {"state": {"data": {"entity": {
|
||||||
|
"name": "Late Night",
|
||||||
|
"trackList": [
|
||||||
|
{"title": "The Color Violet", "subtitle": "Tory Lanez"},
|
||||||
|
{"title": "No Pole", "subtitle": "Don Toliver"},
|
||||||
|
]}}}}}})
|
||||||
|
+ "</script></html>")
|
||||||
|
|
||||||
|
APPLE_PAGE = (
|
||||||
|
'<html><script id="serialized-server-data" type="application/json">'
|
||||||
|
+ json.dumps([{"data": {"sections": [
|
||||||
|
{"headerTitle": "Heavy Rotation"},
|
||||||
|
{"items": [
|
||||||
|
{"title": "YUKON", "artistName": "Justin Bieber"},
|
||||||
|
{"title": "LET THE WORLD BURN", "artistName": "Chris Grey"},
|
||||||
|
{"title": "YUKON", "artistName": "Justin Bieber"}, # dupe
|
||||||
|
]}]}}])
|
||||||
|
+ "</script></html>")
|
||||||
|
|
||||||
|
|
||||||
|
def fake_fetch(body):
|
||||||
|
async def _f(url):
|
||||||
|
return body
|
||||||
|
return _f
|
||||||
|
|
||||||
|
|
||||||
|
def make_playlist(pid, name, rows):
|
||||||
|
"""rows: (seq, artist, title, state, imported_path)"""
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("INSERT OR REPLACE INTO playlists (id,name,source,"
|
||||||
|
"source_url,tracks_total) VALUES (?,?,?,?,?)",
|
||||||
|
(pid, name, "spotify", "https://x", len(rows)))
|
||||||
|
for seq, artist, title, state, path in rows:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO jobs (id,state,detail,artist,title,"
|
||||||
|
"imported_path,playlist_id,playlist_seq) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(f"{pid}-{seq}", state, "", artist, title, path, pid, seq))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("\nlink routing")
|
||||||
|
for url, want in [
|
||||||
|
("https://open.spotify.com/playlist/abc123", "spotify"),
|
||||||
|
("https://open.spotify.com/intl-pt/playlist/abc123", "spotify"),
|
||||||
|
("https://music.apple.com/us/playlist/hits/pl.abc-123", "apple"),
|
||||||
|
("https://www.deezer.com/en/playlist/1313621735", "deezer"),
|
||||||
|
]:
|
||||||
|
matched = ("spotify" if app.SPOTIFY_URL.search(url)
|
||||||
|
else "apple" if app.APPLE_URL.search(url)
|
||||||
|
else "deezer" if app.DEEZER_URL.search(url) else "none")
|
||||||
|
check(f"{want} link routes to {want}", matched == want, f"got {matched}")
|
||||||
|
|
||||||
|
print("\nresolvers, against a fixed payload shape")
|
||||||
|
real = app._fetch
|
||||||
|
try:
|
||||||
|
app._fetch = fake_fetch(SPOTIFY_PAGE)
|
||||||
|
name, tracks = asyncio.run(app._spotify_playlist("playlist", "x"))
|
||||||
|
check("spotify name", name == "Late Night", name)
|
||||||
|
check("spotify pulls artist from subtitle",
|
||||||
|
tracks[0] == ("Tory Lanez", "The Color Violet"), tracks[:1])
|
||||||
|
|
||||||
|
app._fetch = fake_fetch(APPLE_PAGE)
|
||||||
|
name, tracks = asyncio.run(app._apple_playlist("playlist", "pl.x", "u"))
|
||||||
|
check("apple name", name == "Heavy Rotation", name)
|
||||||
|
check("apple pulls artist from artistName",
|
||||||
|
("Justin Bieber", "YUKON") in tracks, tracks)
|
||||||
|
check("apple de-duplicates repeated entries", len(tracks) == 2, tracks)
|
||||||
|
|
||||||
|
# The failure that actually matters: they changed the page.
|
||||||
|
app._fetch = fake_fetch("<html>nothing useful here</html>")
|
||||||
|
for fn, label in ((app._spotify_playlist, "spotify"),
|
||||||
|
(app._apple_playlist, "apple")):
|
||||||
|
try:
|
||||||
|
asyncio.run(fn("playlist", "x", "u") if label == "apple"
|
||||||
|
else fn("playlist", "x"))
|
||||||
|
check(f"{label} shape change is reported", False, "no raise")
|
||||||
|
except app.PlaylistUnavailable as e:
|
||||||
|
check(f"{label} shape change names the resolver",
|
||||||
|
"needs updating" in str(e), str(e))
|
||||||
|
finally:
|
||||||
|
app._fetch = real
|
||||||
|
|
||||||
|
print("\nthe m3u Navidrome reads")
|
||||||
|
lib = app.LIBRARY_DIR
|
||||||
|
(lib / "Tory Lanez").mkdir(parents=True, exist_ok=True)
|
||||||
|
(lib / "Don Toliver").mkdir(parents=True, exist_ok=True)
|
||||||
|
a = lib / "Tory Lanez" / "The Color Violet.flac"; a.write_text("x")
|
||||||
|
b = lib / "Don Toliver" / "No Pole.flac"; b.write_text("x")
|
||||||
|
|
||||||
|
make_playlist("p1", "Late Night", [
|
||||||
|
(1, "Don Toliver", "No Pole", "imported", str(b)),
|
||||||
|
(0, "Tory Lanez", "The Color Violet", "imported", str(a)),
|
||||||
|
(2, "Nobody", "Missing", "failed", None),
|
||||||
|
])
|
||||||
|
dest = app.write_playlist("p1")
|
||||||
|
text = dest.read_text()
|
||||||
|
check("m3u is written", dest and dest.exists(), dest)
|
||||||
|
check("declares the playlist name for Navidrome",
|
||||||
|
"#PLAYLIST:Late Night" in text, text)
|
||||||
|
check("uses Navidrome's mount, not muse's",
|
||||||
|
"/music/Tory Lanez/The Color Violet.flac" in text
|
||||||
|
and str(lib) not in text, text)
|
||||||
|
check("keeps the playlist's own order, not insertion order",
|
||||||
|
text.index("The Color Violet") < text.index("No Pole"), text)
|
||||||
|
check("omits tracks that never imported", "Missing" not in text, text)
|
||||||
|
|
||||||
|
print("\nedge cases")
|
||||||
|
make_playlist("p2", "Nothing Landed Yet", [
|
||||||
|
(0, "A", "B", "searching", None)])
|
||||||
|
check("no m3u while the playlist is empty",
|
||||||
|
app.write_playlist("p2") is None)
|
||||||
|
check("and no stray file left behind",
|
||||||
|
not (lib / "Nothing Landed Yet.m3u").exists())
|
||||||
|
|
||||||
|
make_playlist("p3", "../../escape", [
|
||||||
|
(0, "Tory Lanez", "The Color Violet", "imported", str(a))])
|
||||||
|
out = app.write_playlist("p3")
|
||||||
|
check("a traversing playlist name cannot escape the library",
|
||||||
|
out is None or out.resolve().is_relative_to(lib.resolve()), out)
|
||||||
|
|
||||||
|
# A track approved and then rejected has to leave the file.
|
||||||
|
with app.db() as c:
|
||||||
|
c.execute("UPDATE jobs SET state='failed' WHERE id=?", ("p1-0",))
|
||||||
|
app.write_playlist("p1")
|
||||||
|
check("rejecting a track drops it from the m3u",
|
||||||
|
"The Color Violet" not in dest.read_text(), dest.read_text())
|
||||||
|
|
||||||
|
print(f"\n{ok} passed, {fail} failed")
|
||||||
|
return 1 if fail else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Error({ reset }: { error: Error; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-display">Something broke</h1>
|
||||||
|
<p className="mx-auto mt-2 max-w-[46ch] text-body text-dim">
|
||||||
|
The view failed to render. Your requests are unaffected — they run on the server.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
className="focus-ring rounded-control bg-accent px-4 py-2 text-callout
|
||||||
|
font-semibold text-[var(--accent-ink)]"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="focus-ring rounded-control border border-edge-hi px-4 py-2
|
||||||
|
text-callout font-semibold text-ink"
|
||||||
|
>
|
||||||
|
Back to search
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+179
-115
@@ -3,40 +3,67 @@
|
|||||||
/* ───────────────────────────────────────────────────────────────
|
/* ───────────────────────────────────────────────────────────────
|
||||||
muse design system
|
muse design system
|
||||||
|
|
||||||
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
|
||||||
from whatever album is on screen; every tinted surface derives from them,
|
|
||||||
so the page takes on the palette of the music instead of a fixed brand
|
The previous system sampled the album artwork and repainted the whole
|
||||||
colour. JS writes those three numbers and nothing else.
|
interface from it. That is the inverse of how a native application behaves
|
||||||
|
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
|
||||||
|
only saturated thing on screen -- and it is most of why this read as a
|
||||||
|
themed web page rather than an app.
|
||||||
|
|
||||||
|
So there are two colours now:
|
||||||
|
|
||||||
|
--accent fixed. Controls, selection, focus rings. Never sampled.
|
||||||
|
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
|
||||||
|
|
||||||
|
That split also closes a real bug: the focus ring used to be drawn in the
|
||||||
|
sampled colour, so its contrast against the page depended on which album
|
||||||
|
you happened to be looking at.
|
||||||
─────────────────────────────────────────────────────────────── */
|
─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
--color-accent: var(--accent);
|
||||||
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
--color-accent-soft: var(--accent-soft);
|
||||||
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
--color-accent-line: var(--accent-line);
|
||||||
|
--color-accent-ink: var(--accent-ink);
|
||||||
|
|
||||||
|
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||||
|
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||||
|
|
||||||
--color-ink: var(--fg);
|
--color-ink: var(--fg);
|
||||||
--color-dim: var(--dim);
|
--color-dim: var(--dim);
|
||||||
|
--color-mute: var(--mute);
|
||||||
--color-edge: var(--edge);
|
--color-edge: var(--edge);
|
||||||
--color-edge-hi: var(--edge-hi);
|
--color-edge-hi: var(--edge-hi);
|
||||||
--color-faint: var(--faint);
|
--color-faint: var(--faint);
|
||||||
|
|
||||||
|
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
|
||||||
|
for the sidebar and toolbar; previously `glass` was on everything, which
|
||||||
|
is why nothing read as layered. */
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-2: var(--surface-2);
|
||||||
|
--color-surface-3: var(--surface-3);
|
||||||
|
|
||||||
--color-ok: var(--ok);
|
--color-ok: var(--ok);
|
||||||
--color-err: var(--err);
|
--color-err: var(--err);
|
||||||
--color-warn: var(--warn);
|
--color-warn: var(--warn);
|
||||||
|
|
||||||
--radius-glass: 14px;
|
--radius-control: 8px;
|
||||||
--radius-panel: 28px;
|
--radius-card: 12px;
|
||||||
--radius-control: 11px;
|
--radius-panel: 18px;
|
||||||
|
--radius-sheet: 22px;
|
||||||
--radius-pill: 999px;
|
--radius-pill: 999px;
|
||||||
|
|
||||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
/* One shared curve, so unrelated components feel like one physical system. */
|
||||||
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||||
and settle. A single shared curve keeps unrelated components feeling like
|
|
||||||
one physical system. */
|
|
||||||
--ease-spring: linear(
|
--ease-spring: linear(
|
||||||
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* System faces on purpose. next/font self-hosts a webfont, which would make
|
||||||
|
the Docker build reach out to Google, and `font-src 'self'` in the CSP
|
||||||
|
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
|
||||||
|
this is used from, which is the target anyway. */
|
||||||
--font-sans:
|
--font-sans:
|
||||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||||
"Segoe UI", Roboto, sans-serif;
|
"Segoe UI", Roboto, sans-serif;
|
||||||
@@ -45,9 +72,13 @@
|
|||||||
system-ui, "Segoe UI", Roboto, sans-serif;
|
system-ui, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||||
|
|
||||||
/* Type scale. The defining trait of Apple's typography is that tracking
|
/* Type scale. Tracking tightens as size grows -- display text at default
|
||||||
tightens as size grows — display text at default tracking looks loose and
|
tracking looks loose, small text at display tracking looks cramped. */
|
||||||
amateurish, and small text at display tracking looks cramped. */
|
--text-hero: 2.75rem;
|
||||||
|
--text-hero--line-height: 1.05;
|
||||||
|
--text-hero--letter-spacing: -0.038em;
|
||||||
|
--text-hero--font-weight: 700;
|
||||||
|
|
||||||
--text-display: 2.125rem;
|
--text-display: 2.125rem;
|
||||||
--text-display--line-height: 1.08;
|
--text-display--line-height: 1.08;
|
||||||
--text-display--letter-spacing: -0.033em;
|
--text-display--letter-spacing: -0.033em;
|
||||||
@@ -74,88 +105,113 @@
|
|||||||
--text-caption: 0.6875rem;
|
--text-caption: 0.6875rem;
|
||||||
--text-caption--line-height: 1.35;
|
--text-caption--line-height: 1.35;
|
||||||
--text-caption--letter-spacing: 0.005em;
|
--text-caption--letter-spacing: 0.005em;
|
||||||
|
|
||||||
|
/* Named layout constants. These were arbitrary literals repeated across
|
||||||
|
files: max-w-[940px] twice, gap-[7px] five times. */
|
||||||
|
--spacing-row: 7px;
|
||||||
|
--container-3xl: 1280px;
|
||||||
|
--sidebar-w: 240px;
|
||||||
|
--rail-w: 68px;
|
||||||
|
--toolbar-h: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
|
||||||
|
/* Default artwork tint, used until a cover is sampled. */
|
||||||
--a-r: 120;
|
--a-r: 120;
|
||||||
--a-g: 140;
|
--a-g: 140;
|
||||||
--a-b: 255;
|
--a-b: 255;
|
||||||
|
|
||||||
--bg: #08090d;
|
--bg: #0a0b0f;
|
||||||
--fg: #f2f4f9;
|
--surface: #121319;
|
||||||
--dim: #9aa3b6;
|
--surface-2: #181a22;
|
||||||
--faint: rgb(255 255 255 / 0.07);
|
--surface-3: #20232c;
|
||||||
--glass: rgb(255 255 255 / 0.055);
|
|
||||||
--glass-hi: rgb(255 255 255 / 0.085);
|
|
||||||
--edge: rgb(255 255 255 / 0.1);
|
|
||||||
--edge-hi: rgb(255 255 255 / 0.17);
|
|
||||||
--sheet: rgb(16 18 26 / 0.86);
|
|
||||||
--header: rgb(8 9 13 / 0.62);
|
|
||||||
--scrim: rgb(8 9 13 / 0.74);
|
|
||||||
--scrim-2: rgb(8 9 13 / 0.4);
|
|
||||||
--blur: saturate(180%) blur(24px);
|
|
||||||
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
|
||||||
|
|
||||||
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
--fg: #f4f6fb;
|
||||||
dark background; the same amber on white fails contrast outright, which
|
--dim: #a0a8bb;
|
||||||
is exactly the text a reviewer most needs to read. */
|
--mute: #6d7488;
|
||||||
--ok: #3ddc84;
|
|
||||||
--err: #ff6b6b;
|
--faint: rgb(255 255 255 / 0.06);
|
||||||
--warn: #ffc95c;
|
--edge: rgb(255 255 255 / 0.09);
|
||||||
|
--edge-hi: rgb(255 255 255 / 0.16);
|
||||||
|
|
||||||
|
/* Vibrancy, for sidebar and toolbar only. */
|
||||||
|
--material: rgb(18 19 25 / 0.72);
|
||||||
|
--material-hi: rgb(24 26 34 / 0.86);
|
||||||
|
--blur: saturate(180%) blur(20px);
|
||||||
|
|
||||||
|
--scrim: rgb(10 11 15 / 0.76);
|
||||||
|
--scrim-2: rgb(10 11 15 / 0.35);
|
||||||
|
|
||||||
|
/* Fixed accent. Apple's system blue, which stays legible on both grounds
|
||||||
|
and is independent of whatever is playing. */
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-soft: rgb(10 132 255 / 0.16);
|
||||||
|
--accent-line: rgb(10 132 255 / 0.45);
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
|
||||||
|
--ok: #30d158;
|
||||||
|
--err: #ff453a;
|
||||||
|
--warn: #ff9f0a;
|
||||||
|
|
||||||
/* The specular edge. A real frosted panel catches light along its top rim;
|
/* The specular edge. A real frosted panel catches light along its top rim;
|
||||||
without this, blur alone reads as a flat grey rectangle. This one line is
|
without this, blur alone reads as a flat grey rectangle. */
|
||||||
most of the difference between "translucent" and "material". */
|
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
|
||||||
|
|
||||||
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
||||||
element, and a wide soft one for the light. One alone always looks fake. */
|
element and a wide soft one for the light. One alone always looks fake. */
|
||||||
--e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18);
|
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
|
||||||
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
|
||||||
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #f4f5f8;
|
--bg: #f6f7f9;
|
||||||
--fg: #0e1116;
|
--surface: #ffffff;
|
||||||
--dim: #5a6274;
|
--surface-2: #f2f3f6;
|
||||||
--faint: rgb(10 14 25 / 0.07);
|
--surface-3: #e9ebf0;
|
||||||
--glass: rgb(255 255 255 / 0.7);
|
|
||||||
--glass-hi: rgb(255 255 255 / 0.88);
|
--fg: #0d1117;
|
||||||
--edge: rgb(10 14 25 / 0.09);
|
--dim: #596273;
|
||||||
--edge-hi: rgb(10 14 25 / 0.16);
|
--mute: #858da0;
|
||||||
--sheet: rgb(255 255 255 / 0.95);
|
|
||||||
--header: rgb(244 245 248 / 0.72);
|
--faint: rgb(10 14 25 / 0.05);
|
||||||
--scrim: rgb(255 255 255 / 0.78);
|
--edge: rgb(10 14 25 / 0.1);
|
||||||
--scrim-2: rgb(255 255 255 / 0.5);
|
--edge-hi: rgb(10 14 25 / 0.18);
|
||||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
|
||||||
/* On light material the specular rim is near-white and the shadows have
|
--material: rgb(246 247 249 / 0.72);
|
||||||
to lose most of their weight, or every card looks like it is hovering. */
|
--material-hi: rgb(255 255 255 / 0.88);
|
||||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
|
||||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
--scrim: rgb(255 255 255 / 0.8);
|
||||||
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
--scrim-2: rgb(255 255 255 / 0.45);
|
||||||
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
|
|
||||||
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
|
--accent: #0071e3;
|
||||||
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
--accent-soft: rgb(0 113 227 / 0.12);
|
||||||
|
--accent-line: rgb(0 113 227 / 0.4);
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
|
||||||
|
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
|
||||||
still reads as the same status when the theme flips. */
|
still reads as the same status when the theme flips. */
|
||||||
--ok: #0a7d43;
|
--ok: #0a7d43;
|
||||||
--err: #c62828;
|
--err: #c62828;
|
||||||
--warn: #8a5a00;
|
--warn: #8a5a00;
|
||||||
|
|
||||||
|
/* On light material the specular rim is near-white and the shadows have to
|
||||||
|
lose most of their weight, or every card looks like it is hovering. */
|
||||||
|
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||||
|
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||||
|
|
||||||
|
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
|
||||||
|
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
|
||||||
|
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
@@ -167,8 +223,8 @@
|
|||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
/* Headings take the display face and its tighter tracking automatically,
|
/* Headings take the display face and its tighter tracking automatically, so
|
||||||
so a component never has to remember to ask for it. */
|
a component never has to remember to ask for it. */
|
||||||
h1,
|
h1,
|
||||||
h2,
|
h2,
|
||||||
h3 {
|
h3 {
|
||||||
@@ -189,43 +245,48 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--color-accent-soft);
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility glass {
|
/* Vibrancy, for chrome that sits over scrolling content. */
|
||||||
background: var(--glass);
|
@utility material {
|
||||||
border: 1px solid var(--edge);
|
background: var(--material);
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
box-shadow: var(--spec);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility glass-hi {
|
@utility material-hi {
|
||||||
background: var(--glass-hi);
|
background: var(--material-hi);
|
||||||
border: 1px solid var(--edge-hi);
|
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
box-shadow: var(--spec-hi);
|
}
|
||||||
|
|
||||||
|
/* An opaque content surface. The default for cards, rows and panels. */
|
||||||
|
@utility card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
box-shadow: var(--spec), var(--e1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility sheet {
|
@utility sheet {
|
||||||
background: var(--sheet);
|
background: var(--surface);
|
||||||
border: 1px solid var(--edge-hi);
|
border: 1px solid var(--edge-hi);
|
||||||
backdrop-filter: var(--blur);
|
|
||||||
-webkit-backdrop-filter: var(--blur);
|
|
||||||
box-shadow: var(--spec-hi), var(--e3);
|
box-shadow: var(--spec-hi), var(--e3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
|
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
|
||||||
hover feedback is barely perceptible frame to frame and obvious in
|
feedback is barely perceptible frame to frame and obvious in aggregate.
|
||||||
aggregate. Anything larger reads as a web page, not an interface. */
|
Anything larger reads as a web page, not an interface. */
|
||||||
@utility lift {
|
@utility lift {
|
||||||
transition:
|
transition:
|
||||||
transform 0.28s var(--ease-glass),
|
transform 0.28s var(--ease-out),
|
||||||
box-shadow 0.28s var(--ease-glass),
|
box-shadow 0.28s var(--ease-out),
|
||||||
border-color 0.28s var(--ease-glass),
|
border-color 0.28s var(--ease-out),
|
||||||
background 0.28s var(--ease-glass);
|
background 0.28s var(--ease-out);
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: var(--spec-hi), var(--e2);
|
box-shadow: var(--spec-hi), var(--e2);
|
||||||
@@ -237,11 +298,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard focus only — a mouse click should never draw a ring. */
|
/* ONE focus treatment for the whole app. There used to be two that looked
|
||||||
@utility ring {
|
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
|
||||||
|
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
|
||||||
|
@utility focus-ring {
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
border-radius: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,12 +329,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Very light noise over the gradients — large flat blurs band visibly on
|
|
||||||
8-bit panels without it. */
|
|
||||||
@utility grain {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility no-scrollbar {
|
@utility no-scrollbar {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
@@ -278,14 +336,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="search"]::-webkit-search-cancel-button {
|
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
|
||||||
display: none;
|
is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||||
}
|
elements are blocked outright. Setting element.style from JS is fine -- CSP
|
||||||
|
governs markup, not the CSSOM -- which is why Framer Motion works. */
|
||||||
/* Keyframes live here rather than in a <style> tag inside a component: the
|
|
||||||
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
|
||||||
elements are blocked outright. Setting element.style from JS is fine —
|
|
||||||
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
to {
|
to {
|
||||||
background-position: -220% 0;
|
background-position: -220% 0;
|
||||||
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
|
|||||||
.shimmer {
|
.shimmer {
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
100deg,
|
100deg,
|
||||||
var(--glass) 30%,
|
var(--surface-2) 30%,
|
||||||
var(--glass-hi) 50%,
|
var(--surface-3) 50%,
|
||||||
var(--glass) 70%
|
var(--surface-2) 70%
|
||||||
);
|
);
|
||||||
background-size: 220% 100%;
|
background-size: 220% 100%;
|
||||||
animation: shimmer 1.4s linear infinite;
|
animation: shimmer 1.4s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Framer's animations are JS-driven and ignore this block entirely, which is
|
||||||
|
why every component also consults useReducedMotion(). This covers the CSS
|
||||||
|
half: shimmer, spinners, and every utility transition above. */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+14
-7
@@ -6,19 +6,26 @@ export const metadata: Metadata = {
|
|||||||
description: "Request a song, get it in your library, correctly tagged.",
|
description: "Request a song, get it in your library, correctly tagged.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
themeColor: "#08090d",
|
|
||||||
width: "device-width",
|
|
||||||
initialScale: 1,
|
|
||||||
viewportFit: "cover",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
||||||
* but it makes the Docker build reach out to Google at build time, and
|
* but it makes the Docker build reach out to Google at build time, and
|
||||||
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||||
* which is the look being aimed at anyway.
|
* which is the look being aimed at anyway.
|
||||||
|
*
|
||||||
|
* themeColor is a pair rather than a single hex: it paints the browser's own
|
||||||
|
* chrome on mobile, and a hardcoded dark value left a black status bar sitting
|
||||||
|
* above a white page for anyone using the light theme.
|
||||||
*/
|
*/
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: [
|
||||||
|
{ media: "(prefers-color-scheme: light)", color: "#f6f7f9" },
|
||||||
|
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0f" },
|
||||||
|
],
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<html lang="en" className="h-full antialiased">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Without this, a bad path drops the user on Next's unstyled white error page,
|
||||||
|
* which does not look like it belongs to anything.
|
||||||
|
*/
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-caption font-semibold uppercase tracking-[0.08em] text-mute">404</p>
|
||||||
|
<h1 className="mt-2 text-display">That page isn’t here</h1>
|
||||||
|
<p className="mx-auto mt-2 max-w-[42ch] text-body text-dim">
|
||||||
|
The link may be stale, or the view may have been renamed.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="focus-ring mt-6 inline-flex rounded-control bg-accent px-4 py-2
|
||||||
|
text-callout font-semibold text-[var(--accent-ink)]"
|
||||||
|
>
|
||||||
|
Back to search
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+105
-277
@@ -2,48 +2,35 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
import { ArrowLeft, SearchX } from "lucide-react";
|
||||||
|
|
||||||
import { api, type Me, type Track } from "@/lib/api";
|
import { api, type Me, type Track } from "@/lib/api";
|
||||||
import { tint } from "@/lib/colour";
|
import { tint } from "@/lib/colour";
|
||||||
import { cn } from "@/lib/utils";
|
import { useNav, type Tab } from "@/lib/nav";
|
||||||
|
import { panel, useMotionOK } from "@/lib/motion";
|
||||||
|
|
||||||
import { Aurora } from "@/components/aurora";
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
import { SearchBar } from "@/components/search-bar";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { AlbumView } from "@/components/album-view";
|
import { AlbumView } from "@/components/album-view";
|
||||||
import { ArtistView } from "@/components/artist-view";
|
import { ArtistView } from "@/components/artist-view";
|
||||||
import { BulkImport } from "@/components/bulk-import";
|
import { BulkImport } from "@/components/bulk-import";
|
||||||
|
import { PlaylistImport } from "@/components/playlist-import";
|
||||||
import { Requests, type JobsSnapshot } from "@/components/requests";
|
import { Requests, type JobsSnapshot } from "@/components/requests";
|
||||||
import { Review } from "@/components/review";
|
import { Review } from "@/components/review";
|
||||||
import { DiscoverPanel } from "@/components/discover";
|
import { DiscoverPanel } from "@/components/discover";
|
||||||
import { Settings } from "@/components/settings";
|
import { Settings } from "@/components/settings";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { Welcome } from "@/components/welcome";
|
||||||
import { PlayerProvider } from "@/components/player";
|
import { PlayerProvider } from "@/components/player";
|
||||||
import { ToastProvider } from "@/components/toast";
|
import { ToastProvider } from "@/components/toast";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
type Tab =
|
import { Sidebar } from "@/components/shell/sidebar";
|
||||||
| "search"
|
import { TabBar } from "@/components/shell/tabbar";
|
||||||
| "discover"
|
import { Toolbar } from "@/components/shell/toolbar";
|
||||||
| "jobs"
|
import { StatusBanner } from "@/components/shell/status-banner";
|
||||||
| "review"
|
import { Button } from "@/components/ui/button";
|
||||||
| "bulk"
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
| "settings"
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||||
| "admin";
|
|
||||||
type View =
|
|
||||||
| { k: "welcome" }
|
|
||||||
| { k: "results"; q: string }
|
|
||||||
| { k: "album"; id: number }
|
|
||||||
| { k: "artist"; id: number };
|
|
||||||
|
|
||||||
const EXAMPLES = [
|
|
||||||
"Radiohead Creep",
|
|
||||||
"Kendrick Lamar Money Trees",
|
|
||||||
"Fleetwood Mac Dreams",
|
|
||||||
"Tame Impala The Less I Know The Better",
|
|
||||||
"Frank Ocean Ivy",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
@@ -55,36 +42,32 @@ export default function Page() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tailwind's `md`. Decides sidebar-vs-rail, and rail-vs-bottom-bar. */
|
||||||
|
function useBreakpoint() {
|
||||||
|
const [w, setW] = React.useState(1280);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const on = () => setW(window.innerWidth);
|
||||||
|
on();
|
||||||
|
window.addEventListener("resize", on);
|
||||||
|
return () => window.removeEventListener("resize", on);
|
||||||
|
}, []);
|
||||||
|
return { rail: w >= 768 && w < 1024 };
|
||||||
|
}
|
||||||
|
|
||||||
function Muse() {
|
function Muse() {
|
||||||
const [tab, setTab] = React.useState<Tab>("search");
|
const [nav, go] = useNav();
|
||||||
const [view, setView] = React.useState<View>({ k: "welcome" });
|
const { rail } = useBreakpoint();
|
||||||
const [history, setHistory] = React.useState<View[]>([]);
|
const ok = useMotionOK();
|
||||||
|
|
||||||
const [results, setResults] = React.useState<Track[] | null>(null);
|
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||||
const [busy, setBusy] = React.useState(false);
|
const [busy, setBusy] = React.useState(false);
|
||||||
const [me, setMe] = React.useState<Me | null>(null);
|
const [me, setMe] = React.useState<Me | null>(null);
|
||||||
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
||||||
|
|
||||||
const go = React.useCallback((next: View) => {
|
|
||||||
setView((cur) => {
|
|
||||||
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setTab("search");
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const back = () => {
|
|
||||||
setHistory((h) => {
|
|
||||||
const prev = h[h.length - 1];
|
|
||||||
if (prev) setView(prev);
|
|
||||||
return h.slice(0, -1);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const doSearch = React.useCallback(
|
const doSearch = React.useCallback(
|
||||||
async (q: string) => {
|
async (q: string) => {
|
||||||
if (!q.trim()) return;
|
if (!q.trim()) return;
|
||||||
go({ k: "results", q });
|
go({ tab: "search", q });
|
||||||
setResults(null);
|
setResults(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
@@ -99,8 +82,24 @@ function Muse() {
|
|||||||
[go],
|
[go],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
// Re-runs the query when the URL carries one, so a shared link and a browser
|
||||||
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
// Back both restore an actual result set rather than an empty screen.
|
||||||
|
const lastQ = React.useRef<string | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (nav.tab !== "search" || nav.view || !nav.q) return;
|
||||||
|
if (lastQ.current === nav.q) return;
|
||||||
|
lastQ.current = nav.q;
|
||||||
|
void doSearch(nav.q);
|
||||||
|
}, [nav.tab, nav.view, nav.q, doSearch]);
|
||||||
|
|
||||||
|
const openAlbum = React.useCallback(
|
||||||
|
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
|
||||||
|
[go, nav.q],
|
||||||
|
);
|
||||||
|
const openArtist = React.useCallback(
|
||||||
|
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
|
||||||
|
[go, nav.q],
|
||||||
|
);
|
||||||
|
|
||||||
const refresh = React.useCallback(async () => {
|
const refresh = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -129,191 +128,64 @@ function Muse() {
|
|||||||
(snap?.counts.searching ?? 0) +
|
(snap?.counts.searching ?? 0) +
|
||||||
(snap?.counts.downloading ?? 0);
|
(snap?.counts.downloading ?? 0);
|
||||||
const held = snap?.counts.review ?? 0;
|
const held = snap?.counts.review ?? 0;
|
||||||
|
|
||||||
const isAdmin = me?.role === "admin";
|
const isAdmin = me?.role === "admin";
|
||||||
|
|
||||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
||||||
// is noise on a single-user install, which is most of them.
|
// is noise on a single-user install, which is most of them.
|
||||||
const quotaLeft =
|
const quotaLeft =
|
||||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
||||||
|
|
||||||
|
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
|
||||||
|
// Review appears exactly when something is waiting on you; an always-present
|
||||||
|
// tab that is empty nine days in ten is chrome.
|
||||||
|
const visible = (t: Tab) =>
|
||||||
|
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
|
||||||
|
|
||||||
|
const onBack = nav.view
|
||||||
|
? () => go({ tab: "search", q: nav.q })
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex min-h-dvh">
|
||||||
|
<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 />
|
{onBack && (
|
||||||
|
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||||
</motion.button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
{nav.view === "album" && nav.id && (
|
||||||
|
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
|
||||||
|
)}
|
||||||
|
{nav.view === "artist" && nav.id && (
|
||||||
|
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
|
||||||
|
)}
|
||||||
|
|
||||||
{view.k === "results" &&
|
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
|
||||||
|
|
||||||
|
{!nav.view &&
|
||||||
|
nav.q &&
|
||||||
(results === null ? (
|
(results === null ? (
|
||||||
<Skeletons />
|
<SkeletonRows n={6} />
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<div className="py-12 text-center text-dim">
|
<EmptyState
|
||||||
No matches. Try adding the artist name.
|
icon={SearchX}
|
||||||
</div>
|
title="No matches"
|
||||||
|
body="Try adding the artist name, or fewer words."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.div layout className="flex flex-col gap-[7px]">
|
<div className="flex flex-col gap-row">
|
||||||
{results.map((t, i) => (
|
{results.map((t, i) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={t.deezer_id}
|
key={t.deezer_id}
|
||||||
@@ -324,71 +196,27 @@ function Muse() {
|
|||||||
onQueued={refresh}
|
onQueued={refresh}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{view.k === "album" && (
|
|
||||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
|
||||||
)}
|
|
||||||
{view.k === "artist" && (
|
|
||||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||||
{tab === "review" && <Review onDecided={refresh} />}
|
{nav.tab === "review" && <Review onDecided={refresh} />}
|
||||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
{nav.tab === "bulk" && (
|
||||||
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
<div className="flex flex-col gap-4">
|
||||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
<PlaylistImport onQueued={refresh} />
|
||||||
|
<BulkImport onQueued={refresh} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nav.tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||||
|
{nav.tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
|
||||||
className="px-5 py-14 text-center"
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
animate={{ y: [0, -7, 0] }}
|
|
||||||
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
|
||||||
border border-accent-line bg-accent-soft text-accent"
|
|
||||||
>
|
|
||||||
<Disc3 className="h-7 w-7" />
|
|
||||||
</motion.div>
|
|
||||||
<h2 className="mb-2 text-display">Find anything</h2>
|
|
||||||
<p className="mx-auto max-w-[46ch] text-body text-dim">
|
|
||||||
Search a song and press{" "}
|
|
||||||
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
|
|
||||||
Enter
|
|
||||||
</kbd>
|
|
||||||
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
|
||||||
your library, tagged.
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
|
||||||
{EXAMPLES.map((e, i) => (
|
|
||||||
<motion.button
|
|
||||||
key={e}
|
|
||||||
initial={{ opacity: 0, y: 8 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ delay: 0.1 + i * 0.05 }}
|
|
||||||
whileHover={{ y: -2 }}
|
|
||||||
onClick={() => onPick(e)}
|
|
||||||
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
|
|
||||||
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
|
||||||
>
|
|
||||||
{e}
|
|
||||||
</motion.button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
|
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-panel p-5">
|
<div className="card rounded-panel p-5">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/l
|
|||||||
import { StatCard } from "@/components/admin/stat-card";
|
import { StatCard } from "@/components/admin/stat-card";
|
||||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||||
import { UsersTable } from "@/components/admin/users-table";
|
import { UsersTable } from "@/components/admin/users-table";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonCards } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function AdminPanel({ me }: { me: string | null }) {
|
export function AdminPanel({ me }: { me: string | null }) {
|
||||||
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
|
|||||||
}, [loadUsers]);
|
}, [loadUsers]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||||
if (!sys || !users) return <Skeletons n={6} />;
|
if (!sys || !users) return <SkeletonCards n={4} />;
|
||||||
|
|
||||||
const s = sys.system;
|
const s = sys.system;
|
||||||
const lib = s.disks.library;
|
const lib = s.disks.library;
|
||||||
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
|||||||
initial={{ opacity: 0, y: -6 }}
|
initial={{ opacity: 0, y: -6 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
|
"card flex items-center gap-2.5 rounded-card px-4 py-2.5 text-callout",
|
||||||
healthy ? "border-ok/30" : "border-err/40",
|
healthy ? "border-ok/30" : "border-err/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -141,7 +141,7 @@ function Breakdown({
|
|||||||
}) {
|
}) {
|
||||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-panel p-5">
|
<div className="card rounded-panel p-5">
|
||||||
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{rows.slice(0, 8).map((r, i) => (
|
{rows.slice(0, 8).map((r, i) => (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function StatCard({
|
|||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||||
className="glass rounded-glass p-3.5"
|
className="card rounded-card p-3.5"
|
||||||
>
|
>
|
||||||
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
|
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function UsersTable({
|
|||||||
const [adding, setAdding] = React.useState(false);
|
const [adding, setAdding] = React.useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-panel p-5">
|
<div className="card rounded-panel p-5">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
@@ -35,7 +35,7 @@ export function UsersTable({
|
|||||||
anyone in — the row is.
|
anyone in — the row is.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
<Button size="pill" variant={adding ? "secondary" : "primary"} onClick={() => setAdding(!adding)}>
|
||||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
{adding ? "Cancel" : "Invite"}
|
{adding ? "Cancel" : "Invite"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -101,7 +101,7 @@ function UserRow({
|
|||||||
exit={{ opacity: 0, scale: 0.98 }}
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass-hi rounded-glass p-3",
|
"card rounded-card p-3",
|
||||||
u.disabled && "opacity-60",
|
u.disabled && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||||
"text-ink outline-none focus:border-accent-line";
|
"text-ink outline-none focus:border-accent-line";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
<div className="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
|
||||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||||
<span className="text-caption text-dim">
|
<span className="text-caption text-dim">
|
||||||
{provider === "github" ? "GitHub username" : "Google email"}
|
{provider === "github" ? "GitHub username" : "Google email"}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||||
import { tint } from "@/lib/colour";
|
import { tint } from "@/lib/colour";
|
||||||
import { Hero } from "@/components/hero";
|
import { Hero } from "@/components/hero";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/components/toast";
|
import { useToast } from "@/components/toast";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { AlertCircle, Disc } from "lucide-react";
|
||||||
|
|
||||||
export function AlbumView({
|
export function AlbumView({
|
||||||
id,
|
id,
|
||||||
@@ -43,8 +44,16 @@ export function AlbumView({
|
|||||||
};
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
if (err)
|
||||||
if (!data) return <Skeletons n={8} />;
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
tone="err"
|
||||||
|
title="Could not load album"
|
||||||
|
body={err}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!data) return <SkeletonRows n={8} />;
|
||||||
|
|
||||||
const { album, tracks } = data;
|
const { album, tracks } = data;
|
||||||
|
|
||||||
@@ -88,6 +97,10 @@ export function AlbumView({
|
|||||||
album.duration ? mmss(album.duration * 1000) : null,
|
album.duration ? mmss(album.duration * 1000) : null,
|
||||||
]}
|
]}
|
||||||
actions={
|
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"}>
|
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||||
{batch === "idle"
|
{batch === "idle"
|
||||||
? `Get all ${tracks.length} tracks`
|
? `Get all ${tracks.length} tracks`
|
||||||
@@ -95,10 +108,18 @@ export function AlbumView({
|
|||||||
? "Queueing…"
|
? "Queueing…"
|
||||||
: `Queued ${queued}`}
|
: `Queued ${queued}`}
|
||||||
</Button>
|
</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) => (
|
{tracks.map((t, i) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={t.deezer_id}
|
key={t.deezer_id}
|
||||||
@@ -110,7 +131,8 @@ export function AlbumView({
|
|||||||
onQueued={onQueued}
|
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 { tint } from "@/lib/colour";
|
||||||
import { Hero } from "@/components/hero";
|
import { Hero } from "@/components/hero";
|
||||||
import { TrackRow } from "@/components/track-row";
|
import { TrackRow } from "@/components/track-row";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
export function ArtistView({
|
export function ArtistView({
|
||||||
id,
|
id,
|
||||||
@@ -42,7 +42,7 @@ export function ArtistView({
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||||
if (!data) return <Skeletons n={8} />;
|
if (!data) return <SkeletonRows n={8} />;
|
||||||
|
|
||||||
const { artist, tracks, albums } = data;
|
const { artist, tracks, albums } = data;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function ArtistView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionHead>Top tracks</SectionHead>
|
<SectionHead>Top tracks</SectionHead>
|
||||||
<motion.div layout className="flex flex-col gap-[7px]">
|
<motion.div layout className="flex flex-col gap-row">
|
||||||
{tracks.map((t, i) => (
|
{tracks.map((t, i) => (
|
||||||
<TrackRow
|
<TrackRow
|
||||||
key={t.deezer_id}
|
key={t.deezer_id}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
|
|
||||||
* the artwork sets, so the page takes on the palette of whatever is on screen.
|
|
||||||
*
|
|
||||||
* Kept faint on purpose: at full strength the gradient competes with the album
|
|
||||||
* art it is derived from, and the glass rows lose contrast against it.
|
|
||||||
*/
|
|
||||||
export function Aurora() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
|
|
||||||
<motion.i
|
|
||||||
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
|
|
||||||
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
|
|
||||||
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
|
|
||||||
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
/>
|
|
||||||
<motion.i
|
|
||||||
className="absolute block rounded-full opacity-30 blur-[110px]
|
|
||||||
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
|
|
||||||
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
|
|
||||||
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
|
|
||||||
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
/>
|
|
||||||
{/* Channel-rotated so the palette has range rather than one flat wash,
|
|
||||||
but faint — a full-strength complement fights the artwork. */}
|
|
||||||
<motion.i
|
|
||||||
className="absolute block rounded-full opacity-[0.14] blur-[110px]
|
|
||||||
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
|
|
||||||
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
|
|
||||||
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
|
|
||||||
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="glass rounded-panel p-5"
|
className="card rounded-panel p-5"
|
||||||
>
|
>
|
||||||
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
|
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
|
||||||
<p className="mb-3.5 max-w-[62ch] text-callout text-dim">
|
<p className="mb-3.5 max-w-[62ch] text-callout text-dim">
|
||||||
@@ -61,7 +61,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
placeholder={PLACEHOLDER}
|
placeholder={PLACEHOLDER}
|
||||||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
className="min-h-[170px] w-full resize-y rounded-card border border-edge
|
||||||
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||||
text-ink outline-none transition
|
text-ink outline-none transition
|
||||||
placeholder:text-dim/65
|
placeholder:text-dim/65
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
|||||||
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
||||||
|
|
||||||
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
|
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { SkeletonGrid } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recommendations and a personal recap, both derived from muse's OWN request
|
* Recommendations and a personal recap, both derived from muse's OWN request
|
||||||
@@ -24,7 +24,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
|||||||
api.recap().then(setR).catch(() => undefined);
|
api.recap().then(setR).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!d) return <Skeletons n={4} />;
|
if (!d) return <SkeletonGrid n={8} />;
|
||||||
|
|
||||||
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
||||||
if (empty)
|
if (empty)
|
||||||
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||||
onClick={() => onArtist(a.name)}
|
onClick={() => onArtist(a.name)}
|
||||||
className="glass lift ring group cursor-pointer rounded-panel p-3 text-center"
|
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
|
||||||
>
|
>
|
||||||
{a.picture ? (
|
{a.picture ? (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
|
|||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="glass rounded-panel p-4"
|
className="card rounded-panel p-4"
|
||||||
>
|
>
|
||||||
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function Hero({
|
|||||||
initial={{ opacity: 0, y: 14 }}
|
initial={{ opacity: 0, y: 14 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
className="card relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||||
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||||
>
|
>
|
||||||
{art && (
|
{art && (
|
||||||
@@ -72,7 +72,7 @@ export function Hero({
|
|||||||
{facts.filter(Boolean).map((f, i) => (
|
{facts.filter(Boolean).map((f, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
key={i}
|
||||||
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
|
className="card rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||||
>
|
>
|
||||||
{f}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { CheckCircle2, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||||
|
import type { Job } from "@/lib/api";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Sheet } from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a request is where it is.
|
||||||
|
*
|
||||||
|
* Previously a failure was a red word in a list plus a three-second toast, and
|
||||||
|
* the reason was raw text — "transfer Completed, Errored" — joined with a dot
|
||||||
|
* after the album name in 13px dim type. Since the backend now walks a ladder
|
||||||
|
* of candidates instead of giving up on the first peer, there is an actual
|
||||||
|
* story to tell: who was tried, what each one said, and what is left.
|
||||||
|
*/
|
||||||
|
export function JobSheet({
|
||||||
|
job,
|
||||||
|
onClose,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
job: Job | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [busy, setBusy] = React.useState(false);
|
||||||
|
|
||||||
|
const retry = async () => {
|
||||||
|
if (!job) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api.retry(job.id);
|
||||||
|
toast("Requeued");
|
||||||
|
onChanged();
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
toast((e as Error).message, true);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const conf = job?.confidence;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet
|
||||||
|
open={!!job}
|
||||||
|
onOpenChange={(v) => !v && onClose()}
|
||||||
|
title={job ? job.title : ""}
|
||||||
|
description={job ? [job.artist, job.album].filter(Boolean).join(" · ") : undefined}
|
||||||
|
footer={
|
||||||
|
job?.state === "failed" ? (
|
||||||
|
<Button onClick={retry} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} Try again
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{job && (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatePill state={job.state} />
|
||||||
|
{job.attempts > 0 && (
|
||||||
|
<span className="text-caption text-mute nums">
|
||||||
|
{job.attempts} peer{job.attempts === 1 ? "" : "s"} tried
|
||||||
|
{job.state !== "failed" && job.remaining > 0
|
||||||
|
? ` · ${job.remaining} left to try`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{job.detail && (
|
||||||
|
<p className="text-callout text-dim">{job.detail}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{conf != null && (
|
||||||
|
<section>
|
||||||
|
<div className="mb-1.5 flex items-baseline justify-between">
|
||||||
|
<h3 className="text-callout font-medium">Match confidence</h3>
|
||||||
|
<span className="text-callout text-dim nums">{Math.round(conf * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={conf * 100}
|
||||||
|
tone={conf >= 0.95 ? "ok" : conf >= 0.7 ? "warn" : "err"}
|
||||||
|
/>
|
||||||
|
{job.match_why && (
|
||||||
|
<p className="mt-1.5 text-caption text-mute">{job.match_why}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.remote_file && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-1.5 text-callout font-medium">File</h3>
|
||||||
|
<p className="break-all rounded-control bg-[var(--surface-2)] p-2.5 font-mono text-caption text-dim">
|
||||||
|
{job.remote_file.replace(/\\/g, "/")}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.tried.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-callout font-medium">
|
||||||
|
Peers tried
|
||||||
|
</h3>
|
||||||
|
<ol className="flex flex-col gap-1.5">
|
||||||
|
{job.tried.map((t, i) => (
|
||||||
|
<li
|
||||||
|
key={`${t.username}-${i}`}
|
||||||
|
className="flex items-start gap-2 rounded-control border border-edge
|
||||||
|
bg-[var(--surface-2)] px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-err" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-callout font-medium">{t.username}</div>
|
||||||
|
<div className="text-caption text-dim">{t.reason}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{job.state === "downloading" && job.username && (
|
||||||
|
<li
|
||||||
|
className="flex items-start gap-2 rounded-control border border-accent-line
|
||||||
|
bg-accent-soft px-2.5 py-2"
|
||||||
|
>
|
||||||
|
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-callout font-medium">{job.username}</div>
|
||||||
|
<div className="text-caption text-dim">downloading now</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatePill({ state }: { state: Job["state"] }) {
|
||||||
|
const map = {
|
||||||
|
imported: { tone: "ok", icon: CheckCircle2, label: "In your library" },
|
||||||
|
failed: { tone: "err", icon: XCircle, label: "Failed" },
|
||||||
|
review: { tone: "accent", icon: CheckCircle2, label: "Waiting on you" },
|
||||||
|
downloading: { tone: "warn", icon: Loader2, label: "Downloading" },
|
||||||
|
searching: { tone: "warn", icon: Loader2, label: "Searching Soulseek" },
|
||||||
|
queued: { tone: "neutral", icon: Loader2, label: "Queued" },
|
||||||
|
} as const;
|
||||||
|
const m = map[state];
|
||||||
|
const Icon = m.icon;
|
||||||
|
return (
|
||||||
|
<Badge tone={m.tone}>
|
||||||
|
<Icon className={`h-3 w-3 ${state === "imported" || state === "failed" ? "" : "animate-spin"}`} />
|
||||||
|
{m.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -134,7 +134,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<button
|
<button
|
||||||
onClick={stop}
|
onClick={stop}
|
||||||
aria-label="Stop preview"
|
aria-label="Stop preview"
|
||||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
|
||||||
text-ink cursor-pointer transition hover:border-accent-line
|
text-ink cursor-pointer transition hover:border-accent-line
|
||||||
hover:text-accent"
|
hover:text-accent"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Link2, ListMusic, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
|
import { api, type PlaylistPreview, type PlaylistRow } from "@/lib/api";
|
||||||
|
import { useToast } from "@/components/toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardTitle, CardNote } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/field";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { useMotionOK, item } from "@/lib/motion";
|
||||||
|
|
||||||
|
const SOURCE: Record<string, string> = {
|
||||||
|
spotify: "Spotify",
|
||||||
|
apple: "Apple Music",
|
||||||
|
deezer: "Deezer",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste a shared link, get the playlist.
|
||||||
|
*
|
||||||
|
* Resolve is deliberately split from queue. A shared link is opaque — you
|
||||||
|
* cannot tell twelve tracks from four hundred by looking at it — and four
|
||||||
|
* hundred is a quota decision, so the tracks are shown before anything is
|
||||||
|
* committed.
|
||||||
|
*/
|
||||||
|
export function PlaylistImport({ onQueued }: { onQueued?: () => void }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const ok = useMotionOK();
|
||||||
|
const [url, setUrl] = React.useState("");
|
||||||
|
const [name, setName] = React.useState("");
|
||||||
|
const [preview, setPreview] = React.useState<PlaylistPreview | null>(null);
|
||||||
|
const [busy, setBusy] = React.useState<"idle" | "resolving" | "queueing">("idle");
|
||||||
|
const [rows, setRows] = React.useState<PlaylistRow[] | null>(null);
|
||||||
|
|
||||||
|
// Not async, and resolving through .then: setState then happens in a promise
|
||||||
|
// callback rather than synchronously inside the effect, which is both what
|
||||||
|
// React wants and what the rest of the polling components here already do.
|
||||||
|
const refresh = React.useCallback(() => {
|
||||||
|
api
|
||||||
|
.playlists()
|
||||||
|
.then((d) => setRows(d.playlists))
|
||||||
|
.catch(() => {
|
||||||
|
/* a 401 here just means the poll outlived the session */
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const t = setInterval(refresh, 5000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const resolve = async () => {
|
||||||
|
if (!url.trim()) return toast("Paste a playlist link first", true);
|
||||||
|
setBusy("resolving");
|
||||||
|
setPreview(null);
|
||||||
|
try {
|
||||||
|
const d = await api.playlistPreview(url.trim());
|
||||||
|
setPreview(d);
|
||||||
|
setName(d.name);
|
||||||
|
} catch (e) {
|
||||||
|
toast((e as Error).message, true);
|
||||||
|
}
|
||||||
|
setBusy("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
const queue = async () => {
|
||||||
|
setBusy("queueing");
|
||||||
|
try {
|
||||||
|
const d = await api.playlistImport(url.trim(), name.trim() || undefined);
|
||||||
|
toast(
|
||||||
|
`Queued ${d.queued.length} of ${d.queued.length + d.skipped.length} from ${d.name}`,
|
||||||
|
);
|
||||||
|
setUrl("");
|
||||||
|
setPreview(null);
|
||||||
|
onQueued?.();
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast((e as Error).message, true);
|
||||||
|
}
|
||||||
|
setBusy("idle");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card pad="lg">
|
||||||
|
<CardTitle>Import a shared playlist</CardTitle>
|
||||||
|
<CardNote className="mt-1 mb-3.5 max-w-[64ch]">
|
||||||
|
Paste a link from Spotify, Apple Music or Deezer. Every track is matched on
|
||||||
|
Deezer and queued like any other request, and the playlist itself appears in
|
||||||
|
Navidrome — growing as tracks arrive rather than all at once.
|
||||||
|
</CardNote>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && busy === "idle" && resolve()}
|
||||||
|
placeholder="https://open.spotify.com/playlist/…"
|
||||||
|
aria-label="Playlist link"
|
||||||
|
inputMode="url"
|
||||||
|
/>
|
||||||
|
<Button onClick={resolve} disabled={busy !== "idle"} className="shrink-0">
|
||||||
|
{busy === "resolving" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Link2 className="h-3.5 w-3.5" />}
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{preview && (
|
||||||
|
<motion.div
|
||||||
|
initial={ok ? { opacity: 0, height: 0 } : false}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
exit={ok ? { opacity: 0, height: 0 } : { opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="mt-4 rounded-card border border-edge bg-[var(--surface-2)] p-3.5">
|
||||||
|
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone="accent">{SOURCE[preview.source] ?? preview.source}</Badge>
|
||||||
|
<span className="text-callout text-dim nums">
|
||||||
|
{preview.count} track{preview.count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<label className="mb-1 block text-caption font-medium text-dim">
|
||||||
|
Name in Navidrome
|
||||||
|
</label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} className="mb-3" />
|
||||||
|
|
||||||
|
<ol className="mb-3 max-h-52 overflow-y-auto text-callout">
|
||||||
|
{preview.tracks.map((t, i) => (
|
||||||
|
<li key={i} className="flex gap-2 border-b border-edge/60 py-1 last:border-0">
|
||||||
|
<span className="w-6 shrink-0 text-right text-mute nums">{i + 1}</span>
|
||||||
|
<span className="clamp1">
|
||||||
|
{t.title} <span className="text-dim">· {t.artist}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{preview.count > preview.tracks.length && (
|
||||||
|
<li className="py-1 text-caption text-mute">
|
||||||
|
…and {preview.count - preview.tracks.length} more
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<Button onClick={queue} disabled={busy !== "idle"}>
|
||||||
|
{busy === "queueing" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Queue all {preview.count}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{rows && rows.length > 0 && (
|
||||||
|
<Card pad="lg">
|
||||||
|
<CardTitle className="mb-3">Playlists</CardTitle>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
{rows.map((p, i) => {
|
||||||
|
const total = p.tracks_total || 1;
|
||||||
|
const settled = p.done + p.failed;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={p.id}
|
||||||
|
{...item(i, ok)}
|
||||||
|
className="rounded-card border border-edge bg-[var(--surface-2)] p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<ListMusic className="h-3.5 w-3.5 shrink-0 text-mute" />
|
||||||
|
<span className="min-w-0 flex-1 text-body font-semibold clamp1">{p.name}</span>
|
||||||
|
<Badge tone="neutral">{SOURCE[p.source] ?? p.source}</Badge>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={(p.done / total) * 100}
|
||||||
|
tone={p.failed && settled >= total ? "warn" : "accent"}
|
||||||
|
size="sm"
|
||||||
|
label={`${p.name} progress`}
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-x-3 text-caption text-dim nums">
|
||||||
|
<span className="text-ok">{p.done} in library</span>
|
||||||
|
{p.pending > 0 && <span>{p.pending} in flight</span>}
|
||||||
|
{p.review > 0 && <span className="text-warn">{p.review} to review</span>}
|
||||||
|
{p.failed > 0 && <span className="text-err">{p.failed} failed</span>}
|
||||||
|
<span className="text-mute">of {p.tracks_total}</span>
|
||||||
|
</div>
|
||||||
|
{p.m3u_path && (
|
||||||
|
<div className="mt-1 flex items-center gap-1 text-caption text-mute">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-ok" />
|
||||||
|
in Navidrome with {p.tracks_written} track
|
||||||
|
{p.tracks_written === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+76
-83
@@ -2,9 +2,14 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { api, type Job } from "@/lib/api";
|
import { Inbox, ChevronRight } from "lucide-react";
|
||||||
import { useToast } from "@/components/toast";
|
import { type Job } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { JobSheet } from "@/components/job-sheet";
|
||||||
|
import { useMotionOK, spring } from "@/lib/motion";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export type JobsSnapshot = {
|
export type JobsSnapshot = {
|
||||||
@@ -13,88 +18,89 @@ export type JobsSnapshot = {
|
|||||||
pending: number;
|
pending: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PILL: Record<Job["state"], string> = {
|
/**
|
||||||
imported: "text-ok border-ok/40 bg-ok/10",
|
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||||
failed: "text-err border-err/40 bg-err/10",
|
* differed only by the word inside, so the three stages of a request looked
|
||||||
// Held for review, not in flight and not finished — it needs a person, so
|
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||||
// it reads as the accent rather than as another in-progress amber.
|
* now read as distinct.
|
||||||
review: "text-accent border-accent-line bg-accent-soft",
|
*/
|
||||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||||
searching: "text-warn border-warn/40 bg-warn/10",
|
queued: { tone: "neutral", label: "Queued" },
|
||||||
queued: "text-warn border-warn/40 bg-warn/10",
|
searching: { tone: "accent", label: "Searching", live: true },
|
||||||
|
downloading: { tone: "warn", label: "Downloading", live: true },
|
||||||
|
review: { tone: "accent", label: "Needs review" },
|
||||||
|
imported: { tone: "ok", label: "In library" },
|
||||||
|
failed: { tone: "err", label: "Failed" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||||
const toast = useToast();
|
const ok = useMotionOK();
|
||||||
|
const [open, setOpen] = React.useState<string | null>(null);
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
|
|
||||||
const { jobs, counts, pending } = snap;
|
const { jobs, counts } = snap;
|
||||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||||
const done = counts.imported ?? 0;
|
const done = counts.imported ?? 0;
|
||||||
const failed = counts.failed ?? 0;
|
const failed = counts.failed ?? 0;
|
||||||
|
const held = counts.review ?? 0;
|
||||||
const total = active + done;
|
const total = active + done;
|
||||||
|
|
||||||
const held = counts.review ?? 0;
|
|
||||||
const stats: [string, number, string][] = [
|
const stats: [string, number, string][] = [
|
||||||
["In flight", active, "text-accent"],
|
["In flight", active, "text-accent"],
|
||||||
["Imported", done, "text-ok"],
|
["In library", done, "text-ok"],
|
||||||
["Held", held, held > 0 ? "text-warn" : ""],
|
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||||
["Failed", failed, "text-err"],
|
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Kept in sync with the poll so the sheet updates live while it is open.
|
||||||
|
const current = jobs.find((j) => j.id === open) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||||
{stats.map(([label, n, colour]) => (
|
{stats.map(([label, n, colour]) => (
|
||||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
<Card key={label} pad="sm">
|
||||||
<motion.b
|
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||||
key={n}
|
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||||
initial={{ opacity: 0, y: -6 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
className={cn("block text-title leading-none nums", colour)}
|
|
||||||
>
|
|
||||||
{n}
|
|
||||||
</motion.b>
|
|
||||||
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{active > 0 && total > 0 && (
|
{active > 0 && total > 0 && (
|
||||||
<motion.div
|
<Progress
|
||||||
initial={{ opacity: 0, height: 0 }}
|
value={(done / total) * 100}
|
||||||
animate={{ opacity: 1, height: 4 }}
|
className="mb-4"
|
||||||
exit={{ opacity: 0, height: 0 }}
|
label="Requests completed"
|
||||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
|
||||||
>
|
|
||||||
<motion.i
|
|
||||||
className="block h-1 rounded-full bg-accent"
|
|
||||||
animate={{ width: `${(done / total) * 100}%` }}
|
|
||||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{jobs.length === 0 ? (
|
{jobs.length === 0 ? (
|
||||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
<EmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="Nothing requested yet"
|
||||||
|
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<motion.div layout className="flex flex-col gap-[7px]">
|
<div className="flex flex-col gap-row">
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{jobs.map((j) => (
|
{jobs.map((j) => {
|
||||||
<motion.div
|
const s = STATE[j.state] ?? STATE.queued;
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
key={j.id}
|
key={j.id}
|
||||||
layout
|
layout={ok}
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.97 }}
|
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
transition={spring}
|
||||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
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 && (
|
{j.cover && (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
<img
|
<img
|
||||||
@@ -110,45 +116,32 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-body font-semibold clamp1">{j.title}</div>
|
<div className="text-body font-semibold clamp1">{j.title}</div>
|
||||||
<div className="mt-px text-callout text-dim clamp1">
|
<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>
|
</div>
|
||||||
|
{j.detail && j.state === "failed" && (
|
||||||
|
<div className="mt-0.5 text-caption text-err clamp1">{j.detail}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{j.state === "failed" && (
|
<Badge tone={s.tone} className="shrink-0">
|
||||||
<Button
|
{s.live && (
|
||||||
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") && (
|
|
||||||
<motion.span
|
<motion.span
|
||||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
className="h-[5px] w-[5px] rounded-full bg-current"
|
||||||
animate={{ opacity: [1, 0.25, 1] }}
|
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
|
||||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{j.state}
|
{s.label}
|
||||||
</span>
|
</Badge>
|
||||||
</motion.div>
|
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
|
||||||
))}
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ import { Check, FileAudio, ShieldQuestion, X } from "lucide-react";
|
|||||||
import { api, bytes, type ReviewItem } from "@/lib/api";
|
import { api, bytes, type ReviewItem } from "@/lib/api";
|
||||||
import { useToast } from "@/components/toast";
|
import { useToast } from "@/components/toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeletons } from "@/components/skeletons";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
|||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||||
if (!items) return <Skeletons n={3} />;
|
if (!items) return <div className="flex flex-col gap-3">{[0,1,2].map((i)=>(<Skeleton key={i} className="h-[196px] rounded-panel" />))}</div>;
|
||||||
|
|
||||||
if (items.length === 0)
|
if (items.length === 0)
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
||||||
border border-edge bg-[var(--glass)] text-dim"
|
border border-edge bg-[var(--surface)] text-dim"
|
||||||
>
|
>
|
||||||
<Check className="h-6 w-6" />
|
<Check className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@ function ReviewCard({
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
||||||
transition={{ type: "spring", stiffness: 340, damping: 30 }}
|
transition={{ type: "spring", stiffness: 340, damping: 30 }}
|
||||||
className="glass rounded-panel p-4 sm:p-5"
|
className="card rounded-panel p-4 sm:p-5"
|
||||||
>
|
>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{item.cover ? (
|
{item.cover ? (
|
||||||
@@ -123,11 +123,11 @@ function ReviewCard({
|
|||||||
<img
|
<img
|
||||||
src={item.cover}
|
src={item.cover}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
|
className="h-16 w-16 shrink-0 rounded-card border border-edge object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
|
className="grid h-16 w-16 shrink-0 place-items-center rounded-card
|
||||||
border border-edge bg-faint text-dim"
|
border border-edge bg-faint text-dim"
|
||||||
>
|
>
|
||||||
<FileAudio className="h-6 w-6" />
|
<FileAudio className="h-6 w-6" />
|
||||||
@@ -145,7 +145,7 @@ function ReviewCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
|
<div className="mt-3.5 rounded-card border border-edge bg-[var(--surface)] p-3">
|
||||||
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
|
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
|
||||||
<ShieldQuestion className="h-3 w-3" /> what was found
|
<ShieldQuestion className="h-3 w-3" /> what was found
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,21 +9,40 @@ import { cn } from "@/lib/utils";
|
|||||||
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void };
|
||||||
|
|
||||||
export function SearchBar({
|
export function SearchBar({
|
||||||
|
initial,
|
||||||
busy,
|
busy,
|
||||||
onSearch,
|
onSearch,
|
||||||
onAlbum,
|
onAlbum,
|
||||||
onArtist,
|
onArtist,
|
||||||
}: {
|
}: {
|
||||||
|
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
||||||
|
initial?: string;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onSearch: (q: string) => void;
|
onSearch: (q: string) => void;
|
||||||
onAlbum: (id: number) => void;
|
onAlbum: (id: number) => void;
|
||||||
onArtist: (id: number) => void;
|
onArtist: (id: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = React.useState("");
|
const [q, setQ] = React.useState(initial ?? "");
|
||||||
const [items, setItems] = React.useState<Item[]>([]);
|
const [items, setItems] = React.useState<Item[]>([]);
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [sel, setSel] = React.useState(-1);
|
const [sel, setSel] = React.useState(-1);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
// 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 seeded = React.useRef(initial);
|
||||||
|
|
||||||
|
// Follows the URL on Back/Forward, but never fights what is being typed:
|
||||||
|
// only a genuinely different incoming query replaces the field. Declared
|
||||||
|
// after the state it sets, or the effect closes over a binding that is not
|
||||||
|
// initialised yet.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (initial === undefined || initial === seeded.current) return;
|
||||||
|
seeded.current = initial;
|
||||||
|
typed.current = false;
|
||||||
|
setQ(initial);
|
||||||
|
setOpen(false);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
// Debounced so a fast typist makes one upstream call, not eight. The
|
// Debounced so a fast typist makes one upstream call, not eight. The
|
||||||
// AbortController matters more than the delay: without it a slow response
|
// AbortController matters more than the delay: without it a slow response
|
||||||
@@ -76,7 +95,7 @@ export function SearchBar({
|
|||||||
];
|
];
|
||||||
setItems(next);
|
setItems(next);
|
||||||
setSel(-1);
|
setSel(-1);
|
||||||
setOpen(next.length > 0);
|
setOpen(typed.current && next.length > 0);
|
||||||
} catch {
|
} catch {
|
||||||
/* aborted or offline — leave the previous list alone */
|
/* aborted or offline — leave the previous list alone */
|
||||||
}
|
}
|
||||||
@@ -129,9 +148,9 @@ export function SearchBar({
|
|||||||
<div className="relative z-50">
|
<div className="relative z-50">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||||
"transition-all duration-250 ease-glass",
|
"transition-all duration-250 ease-out",
|
||||||
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
||||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -140,7 +159,10 @@ export function SearchBar({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => {
|
||||||
|
typed.current = true;
|
||||||
|
setQ(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={key}
|
onKeyDown={key}
|
||||||
onFocus={() => items.length && setOpen(true)}
|
onFocus={() => items.length && setOpen(true)}
|
||||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||||
@@ -161,7 +183,7 @@ export function SearchBar({
|
|||||||
}}
|
}}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
||||||
glass-hi text-dim hover:text-ink cursor-pointer"
|
card text-dim hover:text-ink cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{anonymous && (
|
{anonymous && (
|
||||||
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
|
<div className="card rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||||
Authentication is turned off on this instance, so there is no account to
|
Authentication is turned off on this instance, so there is no account to
|
||||||
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
|
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
|
||||||
to <code className="font-mono text-[12px]">github</code> or{" "}
|
to <code className="font-mono text-[12px]">github</code> or{" "}
|
||||||
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section className="glass rounded-panel p-5">
|
<section className="card rounded-panel p-5">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<Mail className="h-4 w-4 text-accent" />
|
<Mail className="h-4 w-4 text-accent" />
|
||||||
<h2 className="text-headline">Notifications</h2>
|
<h2 className="text-headline">Notifications</h2>
|
||||||
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!me.mail_configured && (
|
{!me.mail_configured && (
|
||||||
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
|
<div className="mb-4 rounded-card border border-warn/40 bg-[var(--surface)] p-3 text-callout text-warn">
|
||||||
No SMTP server is configured on this instance, so nothing will actually be
|
No SMTP server is configured on this instance, so nothing will actually be
|
||||||
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
|
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
|
||||||
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
|
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
|
||||||
@@ -79,7 +79,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
|
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
|
||||||
disabled={anonymous}
|
disabled={anonymous}
|
||||||
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
|
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
|
||||||
className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
className="focus-ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
||||||
text-body outline-none transition focus:border-accent-line
|
text-body outline-none transition focus:border-accent-line
|
||||||
disabled:opacity-45"
|
disabled:opacity-45"
|
||||||
/>
|
/>
|
||||||
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={notify}
|
aria-checked={notify}
|
||||||
disabled={anonymous}
|
disabled={anonymous}
|
||||||
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
|
className="focus-ring mt-4 flex cursor-pointer items-center gap-3 rounded-card
|
||||||
text-left disabled:cursor-default disabled:opacity-45"
|
text-left disabled:cursor-default disabled:opacity-45"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="glass rounded-panel p-5">
|
<section className="card rounded-panel p-5">
|
||||||
<h2 className="mb-3 text-headline">Account</h2>
|
<h2 className="mb-3 text-headline">Account</h2>
|
||||||
{anonymous ? (
|
{anonymous ? (
|
||||||
<p className="text-callout text-dim">
|
<p className="text-callout text-dim">
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Sparkles,
|
||||||
|
ListMusic,
|
||||||
|
ShieldCheck,
|
||||||
|
ClipboardList,
|
||||||
|
Settings2,
|
||||||
|
Gauge,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { Tab } from "@/lib/nav";
|
||||||
|
|
||||||
|
export type NavItem = {
|
||||||
|
tab: Tab;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
group: "Library" | "Activity" | "Manage";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grouped, because a flat list of seven is a menu and a grouped list of seven
|
||||||
|
* is a structure. The order is the order of use: you search, you watch what
|
||||||
|
* you asked for, you administer occasionally.
|
||||||
|
*/
|
||||||
|
export const NAV: NavItem[] = [
|
||||||
|
{ tab: "search", label: "Search", icon: Search, group: "Library" },
|
||||||
|
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
|
||||||
|
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
|
||||||
|
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
|
||||||
|
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
|
||||||
|
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
|
||||||
|
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The five that earn a slot on a phone's bottom bar. */
|
||||||
|
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Music2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||||
|
import { useMotionOK, spring } from "@/lib/motion";
|
||||||
|
import { NAV } from "./nav-items";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
|
||||||
|
* Settings. It replaces a horizontally scrolling row of pills, which is a
|
||||||
|
* mobile-web affordance that had been left on the desktop layout too.
|
||||||
|
*
|
||||||
|
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
|
||||||
|
* would eat a third of the window.
|
||||||
|
*/
|
||||||
|
export function Sidebar({
|
||||||
|
nav,
|
||||||
|
go,
|
||||||
|
badges,
|
||||||
|
rail,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
nav: Nav;
|
||||||
|
go: (n: Nav) => void;
|
||||||
|
badges: Partial<Record<Tab, number>>;
|
||||||
|
rail: boolean;
|
||||||
|
visible: (t: Tab) => boolean;
|
||||||
|
}) {
|
||||||
|
const ok = useMotionOK();
|
||||||
|
const groups = ["Library", "Activity", "Manage"] as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Sections"
|
||||||
|
className={cn(
|
||||||
|
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
|
||||||
|
"md:flex",
|
||||||
|
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
|
||||||
|
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
|
||||||
|
<Music2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{!rail && <span className="text-headline tracking-tight">muse</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((g) => {
|
||||||
|
const items = NAV.filter((n) => n.group === g && visible(n.tab));
|
||||||
|
if (!items.length) return null;
|
||||||
|
return (
|
||||||
|
<div key={g} className="mb-2 w-full">
|
||||||
|
{!rail && (
|
||||||
|
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
|
||||||
|
{g}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<ul className="flex flex-col gap-0.5">
|
||||||
|
{items.map(({ tab, label, icon: Icon }) => {
|
||||||
|
const on = nav.tab === tab;
|
||||||
|
const badge = badges[tab] ?? 0;
|
||||||
|
return (
|
||||||
|
<li key={tab}>
|
||||||
|
<a
|
||||||
|
href={href({ tab })}
|
||||||
|
aria-current={on ? "page" : undefined}
|
||||||
|
title={rail ? label : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Left-click navigates in place; cmd/ctrl-click still
|
||||||
|
// opens a real new tab, because these are real links.
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
go({ tab });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"focus-ring relative flex items-center rounded-control text-callout",
|
||||||
|
"font-medium transition-colors duration-150",
|
||||||
|
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
|
||||||
|
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="nav-selection"
|
||||||
|
transition={ok ? spring : { duration: 0 }}
|
||||||
|
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className="relative h-4 w-4 shrink-0" />
|
||||||
|
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
|
||||||
|
{badge > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-pill text-caption font-bold nums",
|
||||||
|
rail
|
||||||
|
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
|
||||||
|
: "relative px-1.5",
|
||||||
|
tab === "review"
|
||||||
|
? "bg-warn/20 text-warn"
|
||||||
|
: "bg-accent-soft text-accent",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertTriangle, WifiOff } from "lucide-react";
|
||||||
|
import { api, type Health } from "@/lib/api";
|
||||||
|
import { useMotionOK } from "@/lib/motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System health, for everyone.
|
||||||
|
*
|
||||||
|
* This check already existed but lived inside the admin panel, so a regular
|
||||||
|
* user whose every request was failing saw nothing but a growing pile of red
|
||||||
|
* pills. "Reachable but not logged in" is the important case: the container is
|
||||||
|
* up and healthy, so no other signal reports a problem, yet every search
|
||||||
|
* returns nothing.
|
||||||
|
*/
|
||||||
|
export function StatusBanner() {
|
||||||
|
const [h, setH] = React.useState<Health | null>(null);
|
||||||
|
const ok = useMotionOK();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.health();
|
||||||
|
if (live) setH(r);
|
||||||
|
} catch {
|
||||||
|
/* a 401 here just means the poll outlived the session */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
const t = setInterval(poll, 20000);
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const problem = !h
|
||||||
|
? null
|
||||||
|
: h.slskd !== 200
|
||||||
|
? {
|
||||||
|
icon: WifiOff,
|
||||||
|
text: "Can't reach slskd — nothing will download until it's back.",
|
||||||
|
}
|
||||||
|
: h.logged_in === false
|
||||||
|
? {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
|
||||||
|
}
|
||||||
|
: h.library_writable === false
|
||||||
|
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{problem && (
|
||||||
|
<motion.div
|
||||||
|
role="status"
|
||||||
|
initial={ok ? { height: 0, opacity: 0 } : false}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
|
||||||
|
<problem.icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="clamp1">{problem.text}</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||||
|
import { NAV, MOBILE } from "./nav-items";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phone layout. A bottom bar is what a native app does, and it puts the
|
||||||
|
* targets under the thumb instead of at the top of a tall screen. Respects the
|
||||||
|
* home-indicator inset, which the previous layout did not.
|
||||||
|
*/
|
||||||
|
export function TabBar({
|
||||||
|
nav,
|
||||||
|
go,
|
||||||
|
badges,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
nav: Nav;
|
||||||
|
go: (n: Nav) => void;
|
||||||
|
badges: Partial<Record<Tab, number>>;
|
||||||
|
visible: (t: Tab) => boolean;
|
||||||
|
}) {
|
||||||
|
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Sections"
|
||||||
|
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
|
||||||
|
pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||||
|
>
|
||||||
|
{items.map(({ tab, label, icon: Icon }) => {
|
||||||
|
const on = nav.tab === tab;
|
||||||
|
const badge = badges[tab] ?? 0;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={tab}
|
||||||
|
href={href({ tab })}
|
||||||
|
aria-current={on ? "page" : undefined}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.metaKey || e.ctrlKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
go({ tab });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
|
||||||
|
"text-caption font-medium transition-colors",
|
||||||
|
on ? "text-accent" : "text-mute",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{badge > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
|
||||||
|
"font-bold leading-[15px] nums",
|
||||||
|
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Me } from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sticky chrome over scrolling content: search on the left, identity on the
|
||||||
|
* right. Everything that used to be hidden below `sm:` — the quota chip, the
|
||||||
|
* display name — now degrades into the overflow rather than vanishing, since
|
||||||
|
* "how many requests do I have left" matters most on the device you are
|
||||||
|
* actually queueing from.
|
||||||
|
*/
|
||||||
|
export function Toolbar({
|
||||||
|
me,
|
||||||
|
quotaLeft,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
me: Me | null;
|
||||||
|
quotaLeft: number | null;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="material sticky top-0 z-30 border-b border-edge">
|
||||||
|
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
|
||||||
|
{me?.user && (
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{quotaLeft !== null && (
|
||||||
|
<Badge
|
||||||
|
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
|
||||||
|
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||||
|
className="nums"
|
||||||
|
>
|
||||||
|
{quotaLeft} left
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{me.role && me.role !== "user" && (
|
||||||
|
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
|
||||||
|
{me.role}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{me.avatar && (
|
||||||
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
|
<img
|
||||||
|
src={me.avatar}
|
||||||
|
alt=""
|
||||||
|
className="h-7 w-7 rounded-full border border-edge object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
|
||||||
|
{me.display || me.user}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/auth/logout"
|
||||||
|
title="Log out"
|
||||||
|
aria-label="Log out"
|
||||||
|
className={cn(
|
||||||
|
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
|
||||||
|
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
|
|
||||||
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
|
|
||||||
export function Skeletons({ n = 6 }: { n?: number }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-[7px]">
|
|
||||||
{Array.from({ length: n }).map((_, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
transition={{ delay: i * 0.04 }}
|
|
||||||
className="shimmer h-[68px] rounded-glass border border-edge"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+69
-15
@@ -2,43 +2,97 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useMotionOK, spring } from "@/lib/motion";
|
||||||
|
|
||||||
type Toast = { id: number; msg: string; err?: boolean };
|
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
|
||||||
type Ctx = (msg: string, err?: boolean) => void;
|
type Action = { label: string; run: () => void };
|
||||||
|
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
|
||||||
|
|
||||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||||
export const useToast = () => React.useContext(ToastCtx);
|
export const useToast = () => React.useContext(ToastCtx);
|
||||||
|
|
||||||
|
/** An error you cannot read in time is the same as no error at all. */
|
||||||
|
const DWELL_OK = 3400;
|
||||||
|
const DWELL_ERR = 7000;
|
||||||
|
/** Beyond this the stack is a wall, and the oldest is the least relevant. */
|
||||||
|
const MAX = 3;
|
||||||
|
|
||||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [items, setItems] = React.useState<Toast[]>([]);
|
const [items, setItems] = React.useState<Toast[]>([]);
|
||||||
|
const ok = useMotionOK();
|
||||||
|
|
||||||
const push = React.useCallback<Ctx>((msg, err) => {
|
const drop = React.useCallback(
|
||||||
|
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const push = React.useCallback<Ctx>(
|
||||||
|
(msg, err, action) => {
|
||||||
const id = Date.now() + Math.random();
|
const id = Date.now() + Math.random();
|
||||||
setItems((x) => [...x, { id, msg, err }]);
|
setItems((x) => [...x, { id, msg, err, action }].slice(-MAX));
|
||||||
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
setTimeout(() => drop(id), err ? DWELL_ERR : DWELL_OK);
|
||||||
}, []);
|
},
|
||||||
|
[drop],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastCtx.Provider value={push}>
|
<ToastCtx.Provider value={push}>
|
||||||
{children}
|
{children}
|
||||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
|
{/*
|
||||||
|
aria-live so a screen reader hears "Queued" or a failure at all — these
|
||||||
|
were previously silent, which made the only confirmation that a request
|
||||||
|
had been accepted a purely visual one.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
className="pointer-events-none fixed inset-x-0 bottom-24 z-[110] flex flex-col
|
||||||
|
items-center gap-2 px-4 md:bottom-8"
|
||||||
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{items.map((t) => (
|
{items.map((t) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
layout
|
layout={ok}
|
||||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
|
||||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
transition={spring}
|
||||||
className={cn(
|
className={cn(
|
||||||
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
|
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
|
||||||
"max-w-[calc(100vw-32px)]",
|
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
|
||||||
t.err && "border-err/50 text-err",
|
t.err && "border-err/45",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.msg}
|
{t.err ? (
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 text-err" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||||
|
)}
|
||||||
|
<span className={cn("min-w-0 flex-1", t.err && "text-err")}>{t.msg}</span>
|
||||||
|
{t.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
t.action!.run();
|
||||||
|
drop(t.id);
|
||||||
|
}}
|
||||||
|
className="focus-ring shrink-0 rounded-pill px-2.5 py-0.5 text-callout
|
||||||
|
font-semibold text-accent transition-colors hover:bg-accent-soft"
|
||||||
|
>
|
||||||
|
{t.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => drop(t.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="focus-ring grid h-6 w-6 shrink-0 place-items-center rounded-full
|
||||||
|
text-mute transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export function TrackRow({
|
|||||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||||
whileHover={{ y: -1 }}
|
whileHover={{ y: -1 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
|
||||||
isPlaying && "border-accent-line bg-accent-soft",
|
isPlaying && "border-accent-line bg-accent-soft",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badge = cva(
|
||||||
|
"inline-flex items-center gap-1 rounded-pill border px-2 py-0.5 " +
|
||||||
|
"text-caption font-medium whitespace-nowrap",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
tone: {
|
||||||
|
neutral: "border-edge text-dim",
|
||||||
|
accent: "border-accent-line bg-accent-soft text-accent",
|
||||||
|
ok: "border-ok/40 bg-ok/10 text-ok",
|
||||||
|
warn: "border-warn/40 bg-warn/10 text-warn",
|
||||||
|
err: "border-err/40 bg-err/10 text-err",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { tone: "neutral" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
className,
|
||||||
|
tone,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badge>) {
|
||||||
|
return <span className={cn(badge({ tone }), className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -6,30 +6,36 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const button = cva(
|
const button = cva(
|
||||||
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
|
||||||
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
"select-none whitespace-nowrap focus-ring " +
|
||||||
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
"transition-[background,border-color,color,transform,box-shadow] " +
|
||||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
"duration-150 ease-out active:scale-[0.97] " +
|
||||||
|
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
|
||||||
|
"disabled:active:scale-100",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
// Solid accent. The label is near-black because the accent is clamped
|
// The accent is fixed now, not sampled from artwork, so a solid button
|
||||||
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
// has a known contrast ratio and can carry a plain white label. It
|
||||||
// to stay in step or this button becomes unreadable on some albums.
|
// used to be near-black text on a colour taken from the album cover,
|
||||||
primary:
|
// which only worked because colour.ts clamped the luminance.
|
||||||
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
|
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
|
||||||
"active:scale-[0.97] active:brightness-95",
|
secondary:
|
||||||
ghost:
|
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
|
||||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
"hover:bg-[var(--surface-2)]",
|
||||||
outline:
|
outline:
|
||||||
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
"bg-transparent border border-edge-hi text-ink " +
|
||||||
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
|
||||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
quiet: "bg-transparent text-dim hover:text-ink hover:bg-[var(--faint)]",
|
||||||
|
danger: "bg-transparent border border-err/45 text-err hover:bg-err/10",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: "text-caption px-3 py-1.5 rounded-pill",
|
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
|
||||||
md: "text-callout px-4.5 py-2.5 rounded-control",
|
sm: "text-callout px-3 py-1.5 rounded-control",
|
||||||
pill: "text-callout px-4 py-2 rounded-pill",
|
md: "text-callout px-4 py-2 rounded-control",
|
||||||
|
lg: "text-body px-5 py-2.5 rounded-control font-semibold",
|
||||||
|
icon: "h-8 w-8 rounded-control",
|
||||||
|
pill: "text-callout px-4 py-1.5 rounded-pill",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "primary", size: "md" },
|
defaultVariants: { variant: "primary", size: "md" },
|
||||||
@@ -45,9 +51,7 @@ export interface ButtonProps
|
|||||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
({ className, variant, size, asChild, ...props }, ref) => {
|
({ className, variant, size, asChild, ...props }, ref) => {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot : "button";
|
||||||
return (
|
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
|
||||||
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Button.displayName = "Button";
|
Button.displayName = "Button";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The content surface. `"card rounded-panel p-5"` was copy-pasted across eight
|
||||||
|
* files with the padding drifting between p-3, p-3.5, p-4 and p-5; this fixes
|
||||||
|
* the scale in one place.
|
||||||
|
*/
|
||||||
|
export function Card({
|
||||||
|
className,
|
||||||
|
pad = "md",
|
||||||
|
interactive,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
pad?: "none" | "sm" | "md" | "lg";
|
||||||
|
interactive?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"card rounded-panel",
|
||||||
|
{ none: "", sm: "p-3", md: "p-4", lg: "p-5 sm:p-6" }[pad],
|
||||||
|
interactive && "lift cursor-pointer",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("mb-3 flex items-center justify-between gap-3", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||||
|
return <h2 className={cn("text-headline", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardNote({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||||
|
return <p className={cn("text-callout text-dim", className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seven screens rendered a bare centred string for "nothing here" and two had
|
||||||
|
* a real designed state. This is the designed one, made reusable.
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
action,
|
||||||
|
tone = "neutral",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
body?: React.ReactNode;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
tone?: "neutral" | "err";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col items-center px-6 py-14 text-center", className)}>
|
||||||
|
{Icon && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mb-4 grid h-12 w-12 place-items-center rounded-panel border",
|
||||||
|
tone === "err"
|
||||||
|
? "border-err/35 bg-err/10 text-err"
|
||||||
|
: "border-edge bg-[var(--surface-2)] text-mute",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className={cn("text-headline", tone === "err" && "text-err")}>{title}</h3>
|
||||||
|
{body && <p className="mt-1.5 max-w-[42ch] text-callout text-dim">{body}</p>}
|
||||||
|
{action && <div className="mt-5">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Shared shell for every text control, so focus looks the same everywhere. */
|
||||||
|
export const controlBase =
|
||||||
|
"w-full rounded-control border border-edge bg-[var(--surface-2)] px-3 py-2 " +
|
||||||
|
"text-body text-ink outline-none placeholder:text-mute " +
|
||||||
|
"transition-[border-color,box-shadow,background] duration-150 ease-out " +
|
||||||
|
"focus:border-accent-line focus:bg-[var(--surface)] " +
|
||||||
|
"focus:shadow-[0_0_0_3px_var(--accent-soft)] " +
|
||||||
|
"disabled:opacity-45 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<input ref={ref} className={cn(controlBase, className)} {...props} />
|
||||||
|
));
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<textarea ref={ref} className={cn(controlBase, "resize-y leading-relaxed", className)} {...props} />
|
||||||
|
));
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options inherit the surface token rather than a hardcoded `#12141c`, which
|
||||||
|
* previously rendered dark dropdown items on a white page in light mode.
|
||||||
|
*/
|
||||||
|
export const Select = React.forwardRef<
|
||||||
|
HTMLSelectElement,
|
||||||
|
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<select ref={ref} className={cn(controlBase, "cursor-pointer pr-8", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
));
|
||||||
|
Select.displayName = "Select";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
htmlFor,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
htmlFor?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||||
|
<label htmlFor={htmlFor} className="text-callout font-medium text-dim">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-caption text-mute">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One progress bar. There were six hand-rolled ones with four different
|
||||||
|
* heights, while @radix-ui/react-progress sat installed and unimported.
|
||||||
|
*/
|
||||||
|
export function Progress({
|
||||||
|
value,
|
||||||
|
tone = "accent",
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
value: number; // 0..100
|
||||||
|
tone?: "accent" | "ok" | "warn" | "err" | "art";
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const pct = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(pct)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"w-full overflow-hidden rounded-pill bg-[var(--surface-3)]",
|
||||||
|
size === "sm" ? "h-1" : "h-1.5",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-pill transition-[width] duration-500 ease-out",
|
||||||
|
{ accent: "bg-accent", ok: "bg-ok", warn: "bg-warn", err: "bg-err", art: "bg-art" }[tone],
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useMotionOK } from "@/lib/motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real modal, on Radix, which was already a dependency and never imported.
|
||||||
|
* That matters beyond tidiness: it brings focus trapping, restore-on-close,
|
||||||
|
* Escape, scroll locking and aria wiring that hand-rolled inline confirms
|
||||||
|
* (the previous pattern) do not have.
|
||||||
|
*
|
||||||
|
* Presents as a bottom sheet on phones and a centred panel from `sm:` up.
|
||||||
|
*/
|
||||||
|
export function Sheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const ok = useMotionOK();
|
||||||
|
|
||||||
|
// Radix locks scrolling by injecting a <style> element, which this app's CSP
|
||||||
|
// (style-src 'self', no 'unsafe-inline') blocks outright — the console says
|
||||||
|
// so every time a sheet opens. Setting the property through the CSSOM is not
|
||||||
|
// markup, so it is allowed, and it is the half that actually matters:
|
||||||
|
// without it the page behind the sheet scrolls under your finger on a phone.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<Dialog.Portal forceMount>
|
||||||
|
<Dialog.Overlay asChild forceMount>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: ok ? 0.18 : 0 }}
|
||||||
|
className="fixed inset-0 z-[100] bg-[var(--scrim)] backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
</Dialog.Overlay>
|
||||||
|
<Dialog.Content asChild forceMount>
|
||||||
|
<motion.div
|
||||||
|
initial={ok ? { opacity: 0, y: 24, scale: 0.98 } : false}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={ok ? { opacity: 0, y: 16, scale: 0.98 } : { opacity: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||||
|
className={cn(
|
||||||
|
"sheet fixed z-[101] flex flex-col overflow-hidden",
|
||||||
|
"inset-x-0 bottom-0 max-h-[86vh] rounded-t-sheet rounded-b-none",
|
||||||
|
"sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2",
|
||||||
|
"sm:max-h-[82vh] sm:w-[min(560px,calc(100vw-32px))]",
|
||||||
|
"sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-sheet",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<header className="flex items-start justify-between gap-3 border-b border-edge px-5 py-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<Dialog.Title className="text-headline">{title}</Dialog.Title>
|
||||||
|
{description && (
|
||||||
|
<Dialog.Description className="mt-0.5 text-callout text-dim">
|
||||||
|
{description}
|
||||||
|
</Dialog.Description>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Dialog.Close
|
||||||
|
aria-label="Close"
|
||||||
|
className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center
|
||||||
|
rounded-control text-dim transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Dialog.Close>
|
||||||
|
</header>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||||
|
{footer && (
|
||||||
|
<footer className="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||||
|
{footer}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape-aware. There was one fixed 68px row used for the admin dashboard, the
|
||||||
|
* discover grid and the review queue alike, so the loading state never
|
||||||
|
* resembled what actually arrived.
|
||||||
|
*/
|
||||||
|
export function Skeleton({ className }: { className?: string }) {
|
||||||
|
return <div className={cn("shimmer rounded-card border border-edge", className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonRows({ n = 6 }: { n?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-row">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[64px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCards({ n = 4 }: { n?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[84px] rounded-panel" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonGrid({ n = 8 }: { n?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4">
|
||||||
|
{Array.from({ length: n }, (_, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="aspect-square w-full rounded-panel" />
|
||||||
|
<Skeleton className="h-3 w-3/4 rounded-pill border-0" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** CSS-driven, so the reduced-motion block in globals.css actually stops it. */
|
||||||
|
export function Spinner({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 shrink-0 rounded-full border-2 border-current",
|
||||||
|
"border-r-transparent [animation:spin_0.7s_linear_infinite]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import type { Job } from "@/lib/api";
|
||||||
|
import { item, useMotionOK } from "@/lib/motion";
|
||||||
|
|
||||||
|
const EXAMPLES = [
|
||||||
|
"Radiohead Creep",
|
||||||
|
"Kendrick Lamar Money Trees",
|
||||||
|
"Fleetwood Mac Dreams",
|
||||||
|
"Tame Impala The Less I Know The Better",
|
||||||
|
"Frank Ocean Ivy",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first screen.
|
||||||
|
*
|
||||||
|
* It used to be a floating disc, a heading, and five hardcoded example chips —
|
||||||
|
* a decorated placeholder that said nothing about this install. Leading with
|
||||||
|
* the artwork of what has actually been imported makes the empty state a
|
||||||
|
* library view instead, and falls back to the examples only when there is
|
||||||
|
* genuinely nothing to show.
|
||||||
|
*/
|
||||||
|
export function Welcome({
|
||||||
|
onPick,
|
||||||
|
recent,
|
||||||
|
}: {
|
||||||
|
onPick: (q: string) => void;
|
||||||
|
recent: Job[];
|
||||||
|
}) {
|
||||||
|
const ok = useMotionOK();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const imported = recent
|
||||||
|
.filter((j) => j.state === "imported" && j.cover)
|
||||||
|
.filter((j) => {
|
||||||
|
const k = `${j.artist}|${j.album ?? j.title}`;
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.slice(0, 12);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-2">
|
||||||
|
<div className="mb-8 max-w-[52ch] pt-6">
|
||||||
|
<h1 className="text-hero">Find anything.</h1>
|
||||||
|
<p className="mt-2.5 text-body text-dim">
|
||||||
|
Search a song, preview it, and press Get. It arrives in your library correctly
|
||||||
|
tagged — right artist, right album, right track number.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imported.length > 0 ? (
|
||||||
|
<section aria-labelledby="recent-h">
|
||||||
|
<h2 id="recent-h" className="mb-3 text-headline">
|
||||||
|
Recently added
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-4">
|
||||||
|
{imported.map((j, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={j.id}
|
||||||
|
{...item(i, ok)}
|
||||||
|
onClick={() => onPick(`${j.artist} ${j.title}`)}
|
||||||
|
className="focus-ring group text-left"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={j.cover!}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="lift mb-2 aspect-square w-full rounded-panel border border-edge
|
||||||
|
object-cover shadow-[var(--e1)]"
|
||||||
|
/>
|
||||||
|
<div className="text-callout font-medium clamp1">{j.title}</div>
|
||||||
|
<div className="text-caption text-dim clamp1">{j.artist}</div>
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section aria-labelledby="try-h">
|
||||||
|
<h2 id="try-h" className="mb-3 flex items-center gap-2 text-headline">
|
||||||
|
<Search className="h-4 w-4 text-mute" /> Try one of these
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{EXAMPLES.map((e, i) => (
|
||||||
|
<motion.button
|
||||||
|
key={e}
|
||||||
|
{...item(i, ok)}
|
||||||
|
onClick={() => onPick(e)}
|
||||||
|
className="focus-ring rounded-pill border border-edge bg-[var(--surface)] px-3.5
|
||||||
|
py-1.5 text-callout text-dim transition-colors hover:border-accent-line
|
||||||
|
hover:bg-accent-soft hover:text-accent"
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -129,6 +129,15 @@ export type Job = {
|
|||||||
duration: number | null;
|
duration: number | null;
|
||||||
requested_by: string | null;
|
requested_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** The peer currently serving this transfer, if one is. */
|
||||||
|
username: string | null;
|
||||||
|
remote_file: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
match_why: string | null;
|
||||||
|
/** Peers already attempted for this request, and why each one fell through. */
|
||||||
|
tried: { username: string; filename: string; reason: string }[];
|
||||||
|
attempts: number;
|
||||||
|
remaining: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Usage = { requests: number; storage: number };
|
export type Usage = { requests: number; storage: number };
|
||||||
@@ -248,8 +257,51 @@ function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|||||||
|
|
||||||
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
||||||
|
|
||||||
|
export type Health = {
|
||||||
|
ok: boolean;
|
||||||
|
slskd?: number | string;
|
||||||
|
logged_in?: boolean;
|
||||||
|
shared_files?: number | null;
|
||||||
|
library_writable?: boolean;
|
||||||
|
downloads_readable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistPreview = {
|
||||||
|
source: "deezer" | "spotify" | "apple";
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
tracks: { artist: string; title: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlaylistRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
source_url: string | null;
|
||||||
|
tracks_total: number;
|
||||||
|
tracks_written: number;
|
||||||
|
m3u_path: string | null;
|
||||||
|
created_at: string;
|
||||||
|
done: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
review: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
playlistPreview: (url: string) =>
|
||||||
|
post<PlaylistPreview>("/api/playlist", { url, preview: true }),
|
||||||
|
playlistImport: (url: string, name?: string) =>
|
||||||
|
post<{
|
||||||
|
playlist_id: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
||||||
|
skipped: { line: string; why: string }[];
|
||||||
|
}>("/api/playlist", { url, name }),
|
||||||
|
playlists: () => get<{ playlists: PlaylistRow[] }>("/api/playlists"),
|
||||||
me: () => get<Me>("/api/me"),
|
me: () => get<Me>("/api/me"),
|
||||||
|
health: () => get<Health>("/api/health"),
|
||||||
search: (q: string) =>
|
search: (q: string) =>
|
||||||
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||||
suggest: (q: string, signal?: AbortSignal) =>
|
suggest: (q: string, signal?: AbortSignal) =>
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useReducedMotion, type Transition, type Variants } from "framer-motion";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One motion vocabulary.
|
||||||
|
*
|
||||||
|
* There were four different stagger formulas and five entrance offsets across
|
||||||
|
* the components, so nothing quite moved like anything else. More importantly,
|
||||||
|
* the `prefers-reduced-motion` block in globals.css only governs CSS: Framer
|
||||||
|
* animates via the CSSOM from JS, so every entrance, the aurora blobs and the
|
||||||
|
* pulsing status dots all ran at full strength for people who had explicitly
|
||||||
|
* asked them not to. `useMotionOK()` is the fix, and every animated component
|
||||||
|
* consults it.
|
||||||
|
*/
|
||||||
|
export function useMotionOK() {
|
||||||
|
return !useReducedMotion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple's motion reads as weight, not duration: overshoot slightly, settle. */
|
||||||
|
export const spring: Transition = { type: "spring", stiffness: 420, damping: 34 };
|
||||||
|
export const springSoft: Transition = { type: "spring", stiffness: 260, damping: 30 };
|
||||||
|
|
||||||
|
export const rise: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 8 },
|
||||||
|
shown: { opacity: 1, y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fade: Variants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
shown: { opacity: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capped, so item 400 of a long list does not wait eight seconds for its turn.
|
||||||
|
*/
|
||||||
|
export function stagger(i: number, step = 0.025, max = 0.3) {
|
||||||
|
return Math.min(i * step, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrance props for a list item, honouring the reduced-motion setting.
|
||||||
|
*
|
||||||
|
* Note what the reduced-motion branch does NOT do: return `{}`. Dropping the
|
||||||
|
* props entirely removes `animate` while `initial` has already painted
|
||||||
|
* opacity 0, and the element stays invisible forever. `useReducedMotion()`
|
||||||
|
* also reports false on the first render and flips after mount, so that lands
|
||||||
|
* exactly on the people who asked for less motion and shows them a blank page.
|
||||||
|
* Instead the element is mounted already in its final state.
|
||||||
|
*/
|
||||||
|
export function item(i: number, ok: boolean) {
|
||||||
|
if (!ok) return { initial: false as const, animate: "shown" as const, variants: rise };
|
||||||
|
return {
|
||||||
|
initial: "hidden" as const,
|
||||||
|
animate: "shown" as const,
|
||||||
|
variants: rise,
|
||||||
|
transition: { ...springSoft, delay: stagger(i) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entrance for a whole view. Deliberately translate-only, with no fade.
|
||||||
|
*
|
||||||
|
* This wrapper contains the entire screen, so anything that can leave it at
|
||||||
|
* opacity 0 leaves the app looking broken rather than looking unanimated. A
|
||||||
|
* background tab throttles rAF and freezes an in-flight animation part way —
|
||||||
|
* it resumes on focus, but a stalled fade is a blank page and a stalled slide
|
||||||
|
* is a few pixels of offset nobody notices. Fades stay on individual items,
|
||||||
|
* where a stall is cosmetic.
|
||||||
|
*/
|
||||||
|
const slide: Variants = {
|
||||||
|
hidden: { y: 8 },
|
||||||
|
shown: { y: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function panel(ok: boolean) {
|
||||||
|
if (!ok) return { initial: false as const, animate: "shown" as const, variants: slide };
|
||||||
|
return {
|
||||||
|
initial: "hidden" as const,
|
||||||
|
animate: "shown" as const,
|
||||||
|
variants: slide,
|
||||||
|
transition: { duration: 0.22, ease: [0.22, 0.9, 0.28, 1] as const },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL state for a statically exported app.
|
||||||
|
*
|
||||||
|
* There was none: `tab` and the album/artist view lived in React state alone,
|
||||||
|
* so a refresh always dumped you back at the welcome screen, the browser Back
|
||||||
|
* button did nothing, and no view could be linked to anyone. `output: "export"`
|
||||||
|
* rules out dynamic route segments, so the state rides in the query string and
|
||||||
|
* is driven through the History API.
|
||||||
|
*/
|
||||||
|
export type Tab =
|
||||||
|
| "search"
|
||||||
|
| "discover"
|
||||||
|
| "jobs"
|
||||||
|
| "review"
|
||||||
|
| "bulk"
|
||||||
|
| "settings"
|
||||||
|
| "admin";
|
||||||
|
|
||||||
|
export type Nav = {
|
||||||
|
tab: Tab;
|
||||||
|
view?: "album" | "artist";
|
||||||
|
id?: string;
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TABS: Tab[] = ["search", "discover", "jobs", "review", "bulk", "settings", "admin"];
|
||||||
|
|
||||||
|
export function parse(search: string): Nav {
|
||||||
|
const p = new URLSearchParams(search);
|
||||||
|
const raw = p.get("tab");
|
||||||
|
const tab = (TABS as string[]).includes(raw || "") ? (raw as Tab) : "search";
|
||||||
|
const v = p.get("view");
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
view: v === "album" || v === "artist" ? v : undefined,
|
||||||
|
id: p.get("id") || undefined,
|
||||||
|
q: p.get("q") || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function href(n: Nav): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (n.tab !== "search") p.set("tab", n.tab);
|
||||||
|
if (n.view) p.set("view", n.view);
|
||||||
|
if (n.id) p.set("id", n.id);
|
||||||
|
if (n.q) p.set("q", n.q);
|
||||||
|
const s = p.toString();
|
||||||
|
return s ? `?${s}` : "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the URL and writes it back, without Next's router.
|
||||||
|
*
|
||||||
|
* `next/navigation`'s useSearchParams forces a Suspense boundary under static
|
||||||
|
* export and re-renders the tree on every keystroke of a search query. The
|
||||||
|
* History API is enough for what this needs and keeps Back working.
|
||||||
|
*
|
||||||
|
* useSyncExternalStore rather than useState-in-an-effect: the URL is external
|
||||||
|
* state that React does not own, and this is the hook designed for that. It
|
||||||
|
* also gets the static-export case right — the prerendered HTML has no URL to
|
||||||
|
* read, so the server snapshot is the empty query, and the client subscribes
|
||||||
|
* from its first render without a mismatch.
|
||||||
|
*/
|
||||||
|
const LOCATION_EVENT = "muse:navigate";
|
||||||
|
|
||||||
|
function subscribe(cb: () => void) {
|
||||||
|
window.addEventListener("popstate", cb);
|
||||||
|
window.addEventListener(LOCATION_EVENT, cb);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("popstate", cb);
|
||||||
|
window.removeEventListener(LOCATION_EVENT, cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSearch = () => window.location.search;
|
||||||
|
const serverSearch = () => "";
|
||||||
|
|
||||||
|
export function useNav(): [Nav, (n: Nav, replace?: boolean) => void] {
|
||||||
|
const search = React.useSyncExternalStore(subscribe, clientSearch, serverSearch);
|
||||||
|
const nav = React.useMemo(() => parse(search), [search]);
|
||||||
|
|
||||||
|
const go = React.useCallback((n: Nav, replace = false) => {
|
||||||
|
const url = href(n);
|
||||||
|
if (replace) window.history.replaceState(null, "", url);
|
||||||
|
else window.history.pushState(null, "", url);
|
||||||
|
// pushState does not fire popstate, so tell our own subscribers.
|
||||||
|
window.dispatchEvent(new Event(LOCATION_EVENT));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [nav, go];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user