Compare commits
10
Commits
No files matched your search
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "muse",
|
||||
"runtimeExecutable": "bash",
|
||||
"runtimeArgs": ["dev.sh"],
|
||||
"port": 5041
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,3 +5,8 @@ config/
|
||||
.venv/
|
||||
.DS_Store
|
||||
dev/
|
||||
|
||||
# Local diagnostics: contain the live instance's Soulseek username,
|
||||
# server paths and peer names. Not for a public repo.
|
||||
FINDINGS.md
|
||||
SERVER_AUDIT.md
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
# Build prompt — a self-hostable music request stack
|
||||
|
||||
Paste this into Claude Code in a **fresh empty repo**. It assumes no prior
|
||||
context. Replace `<NAME>` with the project name before you start.
|
||||
|
||||
---
|
||||
|
||||
Build `<NAME>`: a one-command, self-hostable stack that turns "I want this
|
||||
song" into a correctly-tagged file in a personal music library, reachable from
|
||||
any Subsonic client.
|
||||
|
||||
You are creating a public open-source project that strangers will run on their
|
||||
own hardware. Optimise for *someone else's first hour*, not for my convenience.
|
||||
|
||||
## What it is
|
||||
|
||||
Four containers, orchestrated:
|
||||
|
||||
| service | role | image |
|
||||
|---|---|---|
|
||||
| `gluetun` | VPN killswitch that slskd lives behind | `qmcgaw/gluetun` (pin the tag) |
|
||||
| `slskd` | the Soulseek client — the only thing that speaks the protocol | `slskd/slskd` |
|
||||
| `muse` | web front door: search, request, review, admin | `ghcr.io/geneticglitch1/muse` |
|
||||
| `lidarr` | optional: album-level wishlist, retag, import | `lscr.io/linuxserver/lidarr` |
|
||||
|
||||
`muse` is an existing project (github.com/geneticglitch1/muse) — pull its image,
|
||||
do not rebuild or vendor it. Read its README for its environment contract.
|
||||
|
||||
Optionally include `soularr` (`mrusse08/soularr`) to bridge Lidarr's wanted
|
||||
list to slskd, and `navidrome` (`deluan/navidrome`) so the stack is complete
|
||||
end to end for someone with no existing library server.
|
||||
|
||||
## Non-negotiable constraints
|
||||
|
||||
These are not preferences. Each one is a bug I have already paid for.
|
||||
|
||||
**1. Sharing is ON by default, and this is a compliance requirement.**
|
||||
|
||||
Soulseek's rules tolerate alternative clients that "implement the full range of
|
||||
features … including chat, search, wishlist, download, **upload**, and respect /
|
||||
recognition of privileges". A download-only deployment is the thing that rule
|
||||
exists to prevent. So:
|
||||
|
||||
- `slskd` ships with the music library mounted read-only and shared.
|
||||
- The README states plainly, near the top, that the stack shares by default and
|
||||
that this is both the network's rule and the reason the network works.
|
||||
- Disabling sharing must require deliberately editing a value that is commented
|
||||
as antisocial and rule-breaking. Never make it a convenient flag.
|
||||
- Do not add any feature that hides, fakes, or minimises upload participation.
|
||||
|
||||
**2. The VPN must fail closed.**
|
||||
|
||||
If the tunnel drops, slskd must lose network access, not fall back to the naked
|
||||
connection. `network_mode: "service:gluetun"` plus a healthcheck, plus
|
||||
`depends_on: {condition: service_healthy, restart: true}`. Verify this is real
|
||||
by documenting a test that stops gluetun and shows slskd cannot reach the
|
||||
internet.
|
||||
|
||||
**3. AirVPN (and similar) use STATIC reserved ports — gluetun cannot fetch them.**
|
||||
|
||||
gluetun's dynamic port forwarding does not work for AirVPN. Do not set
|
||||
`VPN_PORT_FORWARDING` or slskd's `SLSKD_VPN_PORT_FORWARDING` for those
|
||||
providers — slskd will wait forever for a port that never arrives. Instead the
|
||||
user reserves a port in the provider's control panel and sets it in `.env`, and
|
||||
the compose passes it to **both** `FIREWALL_VPN_INPUT_PORTS` (so inbound peers
|
||||
are admitted on `tun0`) and `SLSKD_SLSK_LISTEN_PORT`.
|
||||
|
||||
Do **not** publish the Soulseek listen port with a docker `ports:` entry. Docker
|
||||
DNAT does nothing for traffic arriving over the tunnel. Say this in a comment,
|
||||
because everyone tries it.
|
||||
|
||||
This distinction — static-reservation providers vs. dynamic-forwarding
|
||||
providers — needs its own docs section with a table, because getting it wrong
|
||||
presents as "downloads are just slow and flaky", never as an error.
|
||||
|
||||
**4. One `/data` mount, never two.**
|
||||
|
||||
`link()` between two different *mounts* returns `EXDEV` even when both are on
|
||||
the same filesystem. Split downloads and library into separate mounts and every
|
||||
import silently degrades to a full copy, storing the library twice. Mount a
|
||||
single `/data` and use subdirectories. Ship a preflight script that creates a
|
||||
hardlink across the two subdirectories and fails loudly if it can't.
|
||||
|
||||
**5. Bind-mount targets must pre-exist, owned by the runtime UID.**
|
||||
|
||||
Docker creates missing bind targets as `root:root`, which breaks every
|
||||
container running as a non-root user. The setup script creates them with the
|
||||
right ownership before first start.
|
||||
|
||||
## Repository shape
|
||||
|
||||
```
|
||||
docker-compose.yml profiles: vpn (default), no-vpn, lidarr, navidrome
|
||||
.env.example every variable, commented, no real values
|
||||
setup.sh interactive first-run: dirs, perms, secrets, preflight
|
||||
docs/
|
||||
README.md landing: what it is, 5-minute quickstart
|
||||
vpn.md providers, static vs dynamic ports, verification
|
||||
exposing.md reverse proxy, TLS, auth — the safe path
|
||||
sharing.md why sharing is on, what the rules say, etiquette
|
||||
troubleshooting.md symptom -> cause table
|
||||
architecture.md what each container does and why it's separate
|
||||
.github/workflows/ lint compose, shellcheck, link-check the docs
|
||||
LICENSE AGPL-3.0 or MIT — ask me which before writing it
|
||||
```
|
||||
|
||||
## The quickstart must actually be five minutes
|
||||
|
||||
```bash
|
||||
git clone … && cd <NAME>
|
||||
./setup.sh # asks for VPN creds, reserved port, Soulseek login
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`setup.sh` is the difference between a project people use and a project people
|
||||
star. It should:
|
||||
|
||||
- detect whether `docker compose` exists and is v2
|
||||
- ask which VPN provider, and branch the port question on static vs dynamic
|
||||
- generate random values for every secret that can be random (`slskd` API key,
|
||||
admin password) rather than asking
|
||||
- create directories with correct ownership
|
||||
- run the hardlink preflight and **refuse to continue** if it fails, explaining
|
||||
why in terms of the consequence ("your library will be stored twice")
|
||||
- write `.env` and print exactly what to do next
|
||||
- be idempotent — safe to re-run
|
||||
|
||||
## Exposing it safely
|
||||
|
||||
This is the section that decides whether the project harms its users, so it
|
||||
gets real care. Cover, in this order of preference:
|
||||
|
||||
1. **Don't.** Tailscale / WireGuard / LAN-only. Recommended default, stated as
|
||||
such. Most people asking do not actually need public access.
|
||||
2. **Reverse proxy with OAuth in front.** Caddy and nginx-proxy-manager configs.
|
||||
muse has GitHub/Google OAuth built in and fails closed without it — show how
|
||||
to configure that, and note the callback URL rule.
|
||||
3. **Never expose slskd or Lidarr directly.** Their auth is not built for the
|
||||
open internet. Only muse, and only behind OAuth.
|
||||
|
||||
Include a short "if you expose this, understand" list: the VPN protects your
|
||||
Soulseek traffic, not your web UI; your reverse proxy sees your real IP; the
|
||||
admin panel can delete files.
|
||||
|
||||
## Compliance and legality — one honest section, no hedging
|
||||
|
||||
A `docs/legal.md`, linked from the README, stating plainly:
|
||||
|
||||
- Soulseek's rules tolerate full-featured alternative clients; slskd is one, and
|
||||
this stack does not modify its protocol behaviour.
|
||||
- Uploading is part of that; this stack shares by default and you should not
|
||||
turn it off.
|
||||
- Whether any given file is legal to download is on the operator, exactly as
|
||||
with any download client. Quote Soulseek's own line: users should only share
|
||||
and download files they are legally allowed to.
|
||||
- This project is not affiliated with or endorsed by Soulseek, and does not
|
||||
bundle or redistribute the official Soulseek client.
|
||||
|
||||
Say it once, clearly, and do not repeat it apologetically elsewhere.
|
||||
|
||||
## Engineering standards
|
||||
|
||||
- **Multi-arch.** amd64 and arm64. A large share of self-hosters are on a Pi or
|
||||
an Apple-silicon Mac; an amd64-only compose is a wall of exec-format errors.
|
||||
- **Pin every image tag.** `:latest` on a VPN container means an unattended pull
|
||||
can drop the tunnel. Pin, and document how to update deliberately.
|
||||
- **Healthchecks on everything** that something else depends on.
|
||||
- **Resource limits** and log rotation (`max-size`, `max-file`) — an unbounded
|
||||
json-file log has filled a homelab disk before.
|
||||
- **No secrets in the repo, ever.** `.env` is gitignored; `.env.example` holds
|
||||
placeholders only. Add a CI check that greps for anything key-shaped.
|
||||
- **Comment the non-obvious, not the obvious.** Every constraint above earns a
|
||||
comment explaining the *consequence* of getting it wrong, not a restatement of
|
||||
the line of YAML.
|
||||
|
||||
## Verification — do this, don't assume it
|
||||
|
||||
Before telling me it works:
|
||||
|
||||
1. `docker compose config` renders with `.env.example` values filled in.
|
||||
2. Both VPN profiles come up; `docker exec gluetun wget -qO- ifconfig.me` shows
|
||||
a non-residential IP, and the same command in a no-VPN profile shows yours.
|
||||
3. Kill gluetun; confirm slskd has no internet. This is the killswitch test.
|
||||
4. The hardlink preflight passes, and a file imported into the library shows
|
||||
link count 2.
|
||||
5. slskd's UI reports connected, logged in, and a non-zero shared file count.
|
||||
6. muse loads, search returns results, a request reaches slskd.
|
||||
7. `shellcheck setup.sh` is clean; `setup.sh` is idempotent.
|
||||
|
||||
Report what you actually ran and what it printed. If something can't be tested
|
||||
in your environment, say so explicitly rather than implying it passed.
|
||||
|
||||
## Work order
|
||||
|
||||
Ask me about the licence and the name first if I haven't said. Then:
|
||||
|
||||
1. `docker-compose.yml` + `.env.example` + `setup.sh` — the working core.
|
||||
2. Verify it, per the list above.
|
||||
3. Docs, written against the thing that now exists rather than the plan.
|
||||
4. CI.
|
||||
|
||||
Show me the compose file and get agreement before writing the docs. Don't
|
||||
generate all of it and ask me to review a hundred files at once.
|
||||
@@ -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
|
||||
match nothing are reported back as skipped instead of failing the batch.
|
||||
|
||||
Failed jobs get a **Retry** button. Most Soulseek failures are the peer going
|
||||
offline, not a bad request, so retrying later usually just works.
|
||||
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
|
||||
a request that loses its peer falls through to the next candidate on its own.
|
||||
See "When a peer says no" below.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
|
||||
|
||||
**Picking the file (Soulseek).** Format preference first, then whether the peer
|
||||
can actually serve you (free upload slot, short queue), then filename
|
||||
plausibility, then bitrate. A marginally better file behind 200 queued users
|
||||
loses to a good-enough one available now.
|
||||
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
|
||||
first — a free upload slot and a short queue — then format preference, then
|
||||
filename plausibility, then bitrate. A marginally better file behind 200 queued
|
||||
users loses to a good-enough one available now.
|
||||
|
||||
That order is deliberate and was once the other way round, which was a bug
|
||||
worth naming: format outranked availability, so a FLAC behind a five-thousand
|
||||
deep queue always beat an MP3 that was free to download immediately. Whether
|
||||
the peer can serve you is the single most predictive signal for whether the
|
||||
job ever finishes, so nothing outranks it.
|
||||
|
||||
## Searching for a name the file doesn't use
|
||||
|
||||
Two things go wrong between "what Deezer calls this track" and "what a stranger
|
||||
named the file", and both used to end the request.
|
||||
|
||||
**The Soulseek server filters some artist names.** This is measurable: on a
|
||||
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
|
||||
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
|
||||
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
|
||||
Ocean. Sending one query of `artist title` — which is all muse used to do —
|
||||
means every request for a filtered artist fails reporting "searched 0 files",
|
||||
indistinguishable from a track nobody shares.
|
||||
|
||||
So muse tries a **ladder** of queries, loosest last, and stops at the first
|
||||
rung that yields enough candidates:
|
||||
|
||||
```
|
||||
Michael Jackson Billie Jean 0 responses
|
||||
Billie Jean 251 responses, 1083 files
|
||||
```
|
||||
|
||||
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
|
||||
a `feat.` credit, a leading article, everything after a `/` in a split title,
|
||||
and finally the artist itself. Since most requests are answered by the first
|
||||
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
|
||||
caps how far it will go.
|
||||
|
||||
Dropping the artist from the *query* does not mean ignoring it — 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
|
||||
|
||||
@@ -202,6 +339,16 @@ filename alone:
|
||||
|
||||
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.70 – 0.95** — held in **Review** with the reasons shown.
|
||||
- **< 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_DB` | `/config/muse.db` | job history |
|
||||
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
||||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
|
||||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-track similarity floor |
|
||||
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
|
||||
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
|
||||
| `MUSE_MAX_ATTEMPTS` | `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_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_SMTP_HOST` | — | set to enable email; see **Email** above |
|
||||
| `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>
|
||||
);
|
||||
}
|
||||
+184
-120
@@ -3,40 +3,67 @@
|
||||
/* ───────────────────────────────────────────────────────────────
|
||||
muse design system
|
||||
|
||||
The accent is the ARTWORK. --a-r/g/b hold the dominant colour sampled
|
||||
from whatever album is on screen; every tinted surface derives from them,
|
||||
so the page takes on the palette of the music instead of a fixed brand
|
||||
colour. JS writes those three numbers and nothing else.
|
||||
Governing rule: NEUTRAL CHROME, COLOUR IN CONTENT.
|
||||
|
||||
The previous system sampled the album artwork and repainted the whole
|
||||
interface from it. That is the inverse of how a native application behaves
|
||||
-- Music, Mail and Finder keep their chrome grey and let the artwork be the
|
||||
only saturated thing on screen -- and it is most of why this read as a
|
||||
themed web page rather than an app.
|
||||
|
||||
So there are two colours now:
|
||||
|
||||
--accent fixed. Controls, selection, focus rings. Never sampled.
|
||||
--art-r/g/b sampled from the cover. Hero washes and now-playing only.
|
||||
|
||||
That split also closes a real bug: the focus ring used to be drawn in the
|
||||
sampled colour, so its contrast against the page depended on which album
|
||||
you happened to be looking at.
|
||||
─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@theme inline {
|
||||
--color-accent: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-accent-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
--color-accent-line: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.4);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
--color-accent-line: var(--accent-line);
|
||||
--color-accent-ink: var(--accent-ink);
|
||||
|
||||
--color-art: rgb(var(--a-r) var(--a-g) var(--a-b));
|
||||
--color-art-soft: rgb(var(--a-r) var(--a-g) var(--a-b) / 0.16);
|
||||
|
||||
--color-ink: var(--fg);
|
||||
--color-dim: var(--dim);
|
||||
--color-mute: var(--mute);
|
||||
--color-edge: var(--edge);
|
||||
--color-edge-hi: var(--edge-hi);
|
||||
--color-faint: var(--faint);
|
||||
|
||||
/* Opaque surfaces. Content sits on a real surface and vibrancy is reserved
|
||||
for the sidebar and toolbar; previously `glass` was on everything, which
|
||||
is why nothing read as layered. */
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-2: var(--surface-2);
|
||||
--color-surface-3: var(--surface-3);
|
||||
|
||||
--color-ok: var(--ok);
|
||||
--color-err: var(--err);
|
||||
--color-warn: var(--warn);
|
||||
|
||||
--radius-glass: 14px;
|
||||
--radius-panel: 28px;
|
||||
--radius-control: 11px;
|
||||
--radius-control: 8px;
|
||||
--radius-card: 12px;
|
||||
--radius-panel: 18px;
|
||||
--radius-sheet: 22px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
/* Apple's motion reads as weight, not duration: things overshoot slightly
|
||||
and settle. A single shared curve keeps unrelated components feeling like
|
||||
one physical system. */
|
||||
/* One shared curve, so unrelated components feel like one physical system. */
|
||||
--ease-out: cubic-bezier(0.22, 0.9, 0.28, 1);
|
||||
--ease-spring: linear(
|
||||
0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1
|
||||
);
|
||||
|
||||
/* System faces on purpose. next/font self-hosts a webfont, which would make
|
||||
the Docker build reach out to Google, and `font-src 'self'` in the CSP
|
||||
blocks a CDN outright. -apple-system resolves to SF Pro on the devices
|
||||
this is used from, which is the target anyway. */
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
@@ -45,9 +72,13 @@
|
||||
system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
||||
|
||||
/* Type scale. The defining trait of Apple's typography is that tracking
|
||||
tightens as size grows — display text at default tracking looks loose and
|
||||
amateurish, and small text at display tracking looks cramped. */
|
||||
/* Type scale. Tracking tightens as size grows -- display text at default
|
||||
tracking looks loose, small text at display tracking looks cramped. */
|
||||
--text-hero: 2.75rem;
|
||||
--text-hero--line-height: 1.05;
|
||||
--text-hero--letter-spacing: -0.038em;
|
||||
--text-hero--font-weight: 700;
|
||||
|
||||
--text-display: 2.125rem;
|
||||
--text-display--line-height: 1.08;
|
||||
--text-display--letter-spacing: -0.033em;
|
||||
@@ -74,88 +105,113 @@
|
||||
--text-caption: 0.6875rem;
|
||||
--text-caption--line-height: 1.35;
|
||||
--text-caption--letter-spacing: 0.005em;
|
||||
|
||||
/* Named layout constants. These were arbitrary literals repeated across
|
||||
files: max-w-[940px] twice, gap-[7px] five times. */
|
||||
--spacing-row: 7px;
|
||||
--container-3xl: 1280px;
|
||||
--sidebar-w: 240px;
|
||||
--rail-w: 68px;
|
||||
--toolbar-h: 52px;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Default artwork tint, used until a cover is sampled. */
|
||||
--a-r: 120;
|
||||
--a-g: 140;
|
||||
--a-b: 255;
|
||||
|
||||
--bg: #08090d;
|
||||
--fg: #f2f4f9;
|
||||
--dim: #9aa3b6;
|
||||
--faint: rgb(255 255 255 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.055);
|
||||
--glass-hi: rgb(255 255 255 / 0.085);
|
||||
--edge: rgb(255 255 255 / 0.1);
|
||||
--edge-hi: rgb(255 255 255 / 0.17);
|
||||
--sheet: rgb(16 18 26 / 0.86);
|
||||
--header: rgb(8 9 13 / 0.62);
|
||||
--scrim: rgb(8 9 13 / 0.74);
|
||||
--scrim-2: rgb(8 9 13 / 0.4);
|
||||
--blur: saturate(180%) blur(24px);
|
||||
--shadow: 0 10px 40px rgb(0 0 0 / 0.45);
|
||||
--bg: #0a0b0f;
|
||||
--surface: #121319;
|
||||
--surface-2: #181a22;
|
||||
--surface-3: #20232c;
|
||||
|
||||
/* Semantic colours are per-theme, not fixed. These are tuned to sit on the
|
||||
dark background; the same amber on white fails contrast outright, which
|
||||
is exactly the text a reviewer most needs to read. */
|
||||
--ok: #3ddc84;
|
||||
--err: #ff6b6b;
|
||||
--warn: #ffc95c;
|
||||
--fg: #f4f6fb;
|
||||
--dim: #a0a8bb;
|
||||
--mute: #6d7488;
|
||||
|
||||
--faint: rgb(255 255 255 / 0.06);
|
||||
--edge: rgb(255 255 255 / 0.09);
|
||||
--edge-hi: rgb(255 255 255 / 0.16);
|
||||
|
||||
/* Vibrancy, for sidebar and toolbar only. */
|
||||
--material: rgb(18 19 25 / 0.72);
|
||||
--material-hi: rgb(24 26 34 / 0.86);
|
||||
--blur: saturate(180%) blur(20px);
|
||||
|
||||
--scrim: rgb(10 11 15 / 0.76);
|
||||
--scrim-2: rgb(10 11 15 / 0.35);
|
||||
|
||||
/* Fixed accent. Apple's system blue, which stays legible on both grounds
|
||||
and is independent of whatever is playing. */
|
||||
--accent: #0a84ff;
|
||||
--accent-soft: rgb(10 132 255 / 0.16);
|
||||
--accent-line: rgb(10 132 255 / 0.45);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
--ok: #30d158;
|
||||
--err: #ff453a;
|
||||
--warn: #ff9f0a;
|
||||
|
||||
/* The specular edge. A real frosted panel catches light along its top rim;
|
||||
without this, blur alone reads as a flat grey rectangle. This one line is
|
||||
most of the difference between "translucent" and "material". */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.09);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14);
|
||||
without this, blur alone reads as a flat grey rectangle. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.07);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.12);
|
||||
|
||||
/* Elevation. Two shadows each: a tight contact shadow that anchors the
|
||||
element, and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42);
|
||||
element and a wide soft one for the light. One alone always looks fake. */
|
||||
--e1: 0 1px 2px rgb(0 0 0 / 0.3), 0 4px 12px rgb(0 0 0 / 0.16);
|
||||
--e2: 0 2px 6px rgb(0 0 0 / 0.34), 0 12px 32px rgb(0 0 0 / 0.26);
|
||||
--e3: 0 6px 16px rgb(0 0 0 / 0.38), 0 28px 68px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f5f8;
|
||||
--fg: #0e1116;
|
||||
--dim: #5a6274;
|
||||
--faint: rgb(10 14 25 / 0.07);
|
||||
--glass: rgb(255 255 255 / 0.7);
|
||||
--glass-hi: rgb(255 255 255 / 0.88);
|
||||
--edge: rgb(10 14 25 / 0.09);
|
||||
--edge-hi: rgb(10 14 25 / 0.16);
|
||||
--sheet: rgb(255 255 255 / 0.95);
|
||||
--header: rgb(244 245 248 / 0.72);
|
||||
--scrim: rgb(255 255 255 / 0.78);
|
||||
--scrim-2: rgb(255 255 255 / 0.5);
|
||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
||||
/* On light material the specular rim is near-white and the shadows have
|
||||
to lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14);
|
||||
/* Darkened to clear 4.5:1 on the light background. Same hues, so a status
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f3f6;
|
||||
--surface-3: #e9ebf0;
|
||||
|
||||
--fg: #0d1117;
|
||||
--dim: #596273;
|
||||
--mute: #858da0;
|
||||
|
||||
--faint: rgb(10 14 25 / 0.05);
|
||||
--edge: rgb(10 14 25 / 0.1);
|
||||
--edge-hi: rgb(10 14 25 / 0.18);
|
||||
|
||||
--material: rgb(246 247 249 / 0.72);
|
||||
--material-hi: rgb(255 255 255 / 0.88);
|
||||
|
||||
--scrim: rgb(255 255 255 / 0.8);
|
||||
--scrim-2: rgb(255 255 255 / 0.45);
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-soft: rgb(0 113 227 / 0.12);
|
||||
--accent-line: rgb(0 113 227 / 0.4);
|
||||
--accent-ink: #ffffff;
|
||||
|
||||
/* Darkened to clear 4.5:1 on the light ground. Same hues, so a status
|
||||
still reads as the same status when the theme flips. */
|
||||
--ok: #0a7d43;
|
||||
--err: #c62828;
|
||||
--warn: #8a5a00;
|
||||
|
||||
/* On light material the specular rim is near-white and the shadows have to
|
||||
lose most of their weight, or every card looks like it is hovering. */
|
||||
--spec: inset 0 1px 0 rgb(255 255 255 / 0.9);
|
||||
--spec-hi: inset 0 1px 0 rgb(255 255 255 / 1);
|
||||
|
||||
--e1: 0 1px 2px rgb(20 30 60 / 0.06), 0 4px 12px rgb(20 30 60 / 0.05);
|
||||
--e2: 0 2px 6px rgb(20 30 60 / 0.07), 0 12px 32px rgb(20 30 60 / 0.08);
|
||||
--e3: 0 6px 16px rgb(20 30 60 / 0.09), 0 28px 68px rgb(20 30 60 / 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--font-sans);
|
||||
@@ -167,8 +223,8 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
/* Headings take the display face and its tighter tracking automatically,
|
||||
so a component never has to remember to ask for it. */
|
||||
/* Headings take the display face and its tighter tracking automatically, so
|
||||
a component never has to remember to ask for it. */
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
@@ -189,43 +245,48 @@
|
||||
color: inherit;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-accent-soft);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background: var(--glass);
|
||||
/* Vibrancy, for chrome that sits over scrolling content. */
|
||||
@utility material {
|
||||
background: var(--material);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
}
|
||||
|
||||
@utility material-hi {
|
||||
background: var(--material-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
}
|
||||
|
||||
/* An opaque content surface. The default for cards, rows and panels. */
|
||||
@utility card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec);
|
||||
}
|
||||
|
||||
@utility glass-hi {
|
||||
background: var(--glass-hi);
|
||||
border: 1px solid var(--edge-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi);
|
||||
box-shadow: var(--spec), var(--e1);
|
||||
}
|
||||
|
||||
@utility sheet {
|
||||
background: var(--sheet);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--edge-hi);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--spec-hi), var(--e3);
|
||||
}
|
||||
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's
|
||||
hover feedback is barely perceptible frame to frame and obvious in
|
||||
aggregate. Anything larger reads as a web page, not an interface. */
|
||||
/* A card that lifts on hover. The translate is tiny on purpose: Apple's hover
|
||||
feedback is barely perceptible frame to frame and obvious in aggregate.
|
||||
Anything larger reads as a web page, not an interface. */
|
||||
@utility lift {
|
||||
transition:
|
||||
transform 0.28s var(--ease-glass),
|
||||
box-shadow 0.28s var(--ease-glass),
|
||||
border-color 0.28s var(--ease-glass),
|
||||
background 0.28s var(--ease-glass);
|
||||
transform 0.28s var(--ease-out),
|
||||
box-shadow 0.28s var(--ease-out),
|
||||
border-color 0.28s var(--ease-out),
|
||||
background 0.28s var(--ease-out);
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--spec-hi), var(--e2);
|
||||
@@ -237,11 +298,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus only — a mouse click should never draw a ring. */
|
||||
@utility ring {
|
||||
/* ONE focus treatment for the whole app. There used to be two that looked
|
||||
nothing alike -- an outline utility on ~12 elements and a box-shadow ring on
|
||||
buttons -- so keyboard focus changed shape depending on what you tabbed to. */
|
||||
@utility focus-ring {
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,12 +329,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Very light noise over the gradients — large flat blurs band visibly on
|
||||
8-bit panels without it. */
|
||||
@utility grain {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
@@ -278,14 +336,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"]::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the
|
||||
CSP is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine —
|
||||
CSP governs markup, not the CSSOM — which is why Framer Motion works. */
|
||||
/* Keyframes live here rather than in a <style> tag inside a component: the CSP
|
||||
is style-src 'self' with no 'unsafe-inline', so React-rendered <style>
|
||||
elements are blocked outright. Setting element.style from JS is fine -- CSP
|
||||
governs markup, not the CSSOM -- which is why Framer Motion works. */
|
||||
@keyframes shimmer {
|
||||
to {
|
||||
background-position: -220% 0;
|
||||
@@ -295,19 +349,29 @@ input[type="search"]::-webkit-search-cancel-button {
|
||||
.shimmer {
|
||||
background-image: linear-gradient(
|
||||
100deg,
|
||||
var(--glass) 30%,
|
||||
var(--glass-hi) 50%,
|
||||
var(--glass) 70%
|
||||
var(--surface-2) 30%,
|
||||
var(--surface-3) 50%,
|
||||
var(--surface-2) 70%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Framer's animations are JS-driven and ignore this block entirely, which is
|
||||
why every component also consults useReducedMotion(). This covers the CSS
|
||||
half: shimmer, spinners, and every utility transition above. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+14
-7
@@ -6,19 +6,26 @@ export const metadata: Metadata = {
|
||||
description: "Request a song, get it in your library, correctly tagged.",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#08090d",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
/**
|
||||
* System fonts on purpose. next/font self-hosts a webfont — fine for the CSP,
|
||||
* but it makes the Docker build reach out to Google at build time, and
|
||||
* -apple-system resolves to SF Pro on the devices this is actually used from,
|
||||
* which is the look being aimed at anyway.
|
||||
*
|
||||
* themeColor is a pair rather than a single hex: it paints the browser's own
|
||||
* chrome on mobile, and a hardcoded dark value left a black status bar sitting
|
||||
* above a white page for anyone using the light theme.
|
||||
*/
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f6f7f9" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0b0f" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/**
|
||||
* Without this, a bad path drops the user on Next's unstyled white error page,
|
||||
* which does not look like it belongs to anything.
|
||||
*/
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="grid min-h-dvh place-items-center px-6 text-center">
|
||||
<div>
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.08em] text-mute">404</p>
|
||||
<h1 className="mt-2 text-display">That page isn’t here</h1>
|
||||
<p className="mx-auto mt-2 max-w-[42ch] text-body text-dim">
|
||||
The link may be stale, or the view may have been renamed.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring mt-6 inline-flex rounded-control bg-accent px-4 py-2
|
||||
text-callout font-semibold text-[var(--accent-ink)]"
|
||||
>
|
||||
Back to search
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+105
-277
@@ -2,48 +2,35 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Disc3, Music2 } from "lucide-react";
|
||||
import { ArrowLeft, SearchX } from "lucide-react";
|
||||
|
||||
import { api, type Me, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNav, type Tab } from "@/lib/nav";
|
||||
import { panel, useMotionOK } from "@/lib/motion";
|
||||
|
||||
import { Aurora } from "@/components/aurora";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { AlbumView } from "@/components/album-view";
|
||||
import { ArtistView } from "@/components/artist-view";
|
||||
import { BulkImport } from "@/components/bulk-import";
|
||||
import { PlaylistImport } from "@/components/playlist-import";
|
||||
import { Requests, type JobsSnapshot } from "@/components/requests";
|
||||
import { Review } from "@/components/review";
|
||||
import { DiscoverPanel } from "@/components/discover";
|
||||
import { Settings } from "@/components/settings";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { Welcome } from "@/components/welcome";
|
||||
import { PlayerProvider } from "@/components/player";
|
||||
import { ToastProvider } from "@/components/toast";
|
||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||
|
||||
type Tab =
|
||||
| "search"
|
||||
| "discover"
|
||||
| "jobs"
|
||||
| "review"
|
||||
| "bulk"
|
||||
| "settings"
|
||||
| "admin";
|
||||
type View =
|
||||
| { k: "welcome" }
|
||||
| { k: "results"; q: string }
|
||||
| { k: "album"; id: number }
|
||||
| { k: "artist"; id: number };
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
import { Sidebar } from "@/components/shell/sidebar";
|
||||
import { TabBar } from "@/components/shell/tabbar";
|
||||
import { Toolbar } from "@/components/shell/toolbar";
|
||||
import { StatusBanner } from "@/components/shell/status-banner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
@@ -55,36 +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() {
|
||||
const [tab, setTab] = React.useState<Tab>("search");
|
||||
const [view, setView] = React.useState<View>({ k: "welcome" });
|
||||
const [history, setHistory] = React.useState<View[]>([]);
|
||||
const [nav, go] = useNav();
|
||||
const { rail } = useBreakpoint();
|
||||
const ok = useMotionOK();
|
||||
|
||||
const [results, setResults] = React.useState<Track[] | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [me, setMe] = React.useState<Me | null>(null);
|
||||
const [snap, setSnap] = React.useState<JobsSnapshot | null>(null);
|
||||
|
||||
const go = React.useCallback((next: View) => {
|
||||
setView((cur) => {
|
||||
setHistory((h) => (cur.k === "welcome" ? h : [...h, cur]));
|
||||
return next;
|
||||
});
|
||||
setTab("search");
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const back = () => {
|
||||
setHistory((h) => {
|
||||
const prev = h[h.length - 1];
|
||||
if (prev) setView(prev);
|
||||
return h.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const doSearch = React.useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) return;
|
||||
go({ k: "results", q });
|
||||
go({ tab: "search", q });
|
||||
setResults(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -99,8 +82,24 @@ function Muse() {
|
||||
[go],
|
||||
);
|
||||
|
||||
const openAlbum = React.useCallback((id: number) => go({ k: "album", id }), [go]);
|
||||
const openArtist = React.useCallback((id: number) => go({ k: "artist", id }), [go]);
|
||||
// Re-runs the query when the URL carries one, so a shared link and a browser
|
||||
// Back both restore an actual result set rather than an empty screen.
|
||||
const lastQ = React.useRef<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (nav.tab !== "search" || nav.view || !nav.q) return;
|
||||
if (lastQ.current === nav.q) return;
|
||||
lastQ.current = nav.q;
|
||||
void doSearch(nav.q);
|
||||
}, [nav.tab, nav.view, nav.q, doSearch]);
|
||||
|
||||
const openAlbum = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "album", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
const openArtist = React.useCallback(
|
||||
(id: number) => go({ tab: "search", view: "artist", id: String(id), q: nav.q }),
|
||||
[go, nav.q],
|
||||
);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -129,191 +128,64 @@ function Muse() {
|
||||
(snap?.counts.searching ?? 0) +
|
||||
(snap?.counts.downloading ?? 0);
|
||||
const held = snap?.counts.review ?? 0;
|
||||
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
||||
// is noise on a single-user install, which is most of them.
|
||||
const quotaLeft =
|
||||
me?.quota && me.usage ? Math.max(0, me.quota - me.usage.requests) : null;
|
||||
|
||||
const badges: Partial<Record<Tab, number>> = { jobs: active, review: held };
|
||||
// Review appears exactly when something is waiting on you; an always-present
|
||||
// tab that is empty nine days in ten is chrome.
|
||||
const visible = (t: Tab) =>
|
||||
t === "admin" ? !!isAdmin : t === "review" ? held > 0 || nav.tab === "review" : true;
|
||||
|
||||
const onBack = nav.view
|
||||
? () => go({ tab: "search", q: nav.q })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh">
|
||||
<Sidebar nav={nav} go={go} badges={badges} rail={rail} visible={visible} />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Toolbar me={me} quotaLeft={quotaLeft}>
|
||||
<SearchBar initial={nav.q} busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
</Toolbar>
|
||||
<StatusBanner />
|
||||
|
||||
<main className="mx-auto w-full max-w-[var(--container-3xl)] flex-1 px-4 pb-28 pt-5 md:pb-16">
|
||||
<motion.div key={nav.tab + (nav.view ?? "") + (nav.id ?? "")} {...panel(ok)}>
|
||||
{nav.tab === "search" && (
|
||||
<>
|
||||
<Aurora />
|
||||
|
||||
<header
|
||||
className="sticky top-0 z-40 border-b border-edge bg-[var(--header)] pt-3.5
|
||||
backdrop-blur-[22px] backdrop-saturate-[1.7]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[940px] px-4">
|
||||
<div className="mb-3.5 flex items-center gap-3">
|
||||
<motion.div
|
||||
whileHover={{ rotate: 12, scale: 1.06 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
className="grid h-[38px] w-[38px] shrink-0 place-items-center rounded-xl
|
||||
border border-accent-line bg-accent-soft text-accent"
|
||||
>
|
||||
<Music2 className="h-5 w-5" />
|
||||
</motion.div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-title leading-none">muse</h1>
|
||||
<p className="mt-0.5 text-caption text-dim">
|
||||
search · request · it appears in Navidrome
|
||||
</p>
|
||||
</div>
|
||||
{me?.user && (
|
||||
<div className="flex items-center gap-2 text-callout text-dim">
|
||||
{quotaLeft !== null && (
|
||||
<span
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className={cn(
|
||||
"hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline",
|
||||
quotaLeft === 0
|
||||
? "border-err/50 text-err"
|
||||
: quotaLeft <= 5
|
||||
? "border-warn/50 text-warn"
|
||||
: "border-edge text-dim",
|
||||
)}
|
||||
>
|
||||
{quotaLeft} left
|
||||
</span>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full border border-edge"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden font-medium text-ink sm:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
{me.role && me.role !== "user" && (
|
||||
<span
|
||||
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
|
||||
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
|
||||
>
|
||||
{me.role}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="ring rounded-md px-1 transition-colors hover:text-ink"
|
||||
>
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
||||
|
||||
{/* A sliding pill rather than an underline: the moving surface is
|
||||
the selection itself, so the eye tracks one object across the
|
||||
row instead of watching two things fade. layoutId does the
|
||||
interpolation, so it stays correct when tabs appear or vanish
|
||||
with a role change. */}
|
||||
{/* The mask is the scroll affordance: on a narrow screen the last
|
||||
tab fades out at the edge instead of being clipped flat, which
|
||||
is the only cue that there is more to the right. */}
|
||||
<nav
|
||||
className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar
|
||||
[mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)]
|
||||
sm:[mask-image:none]"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["search", "Search", 0],
|
||||
["discover", "Discover", 0],
|
||||
["jobs", "Requests", active],
|
||||
// Only exists when it is actionable. An always-present tab
|
||||
// that is empty nine days in ten is chrome; this one appears
|
||||
// exactly when something is waiting on you.
|
||||
...(held > 0 || tab === "review"
|
||||
? ([["review", "Review", held]] as [Tab, string, number][])
|
||||
: []),
|
||||
["bulk", "Import", 0],
|
||||
["settings", "Settings", 0],
|
||||
...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []),
|
||||
] as [Tab, string, number][]
|
||||
).map(([k, label, badge]) => (
|
||||
<button
|
||||
key={k}
|
||||
onClick={() => setTab(k)}
|
||||
aria-current={tab === k ? "page" : undefined}
|
||||
className={cn(
|
||||
"ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5",
|
||||
"text-callout font-medium transition-colors duration-200",
|
||||
tab === k ? "text-ink" : "text-dim hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{tab === k && (
|
||||
<motion.span
|
||||
layoutId="tab-pill"
|
||||
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
|
||||
transition={{ type: "spring", stiffness: 460, damping: 38 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative flex items-center gap-1.5">
|
||||
{label}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-[17px] rounded-pill px-1 text-caption font-bold nums",
|
||||
k === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[940px] px-4 pb-32 pt-5">
|
||||
{/* Keyed on `tab`, so React remounts and Framer replays the entrance.
|
||||
Deliberately NOT wrapped in AnimatePresence mode="wait": that holds
|
||||
the incoming panel until the outgoing one's exit animation reports
|
||||
finished, which makes navigation depend on an animation completing.
|
||||
Anything that stalls the frame loop — a backgrounded tab, a browser
|
||||
that throttles rAF — then leaves the UI stuck on the old panel with
|
||||
no way forward. A cosmetic transition should never gate navigation. */}
|
||||
<motion.div
|
||||
key={tab}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
{tab === "search" && (
|
||||
<>
|
||||
{history.length > 0 && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
onClick={back}
|
||||
className="glass mb-3.5 flex cursor-pointer items-center gap-1.5 rounded-full
|
||||
px-3 py-1.5 text-[13px] text-dim transition hover:border-edge-hi hover:text-ink"
|
||||
>
|
||||
{onBack && (
|
||||
<Button variant="quiet" size="sm" onClick={onBack} className="mb-3 -ml-2">
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> Back
|
||||
</motion.button>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{view.k === "welcome" && <Welcome onPick={doSearch} />}
|
||||
{nav.view === "album" && nav.id && (
|
||||
<AlbumView id={Number(nav.id)} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{nav.view === "artist" && nav.id && (
|
||||
<ArtistView id={Number(nav.id)} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
|
||||
{view.k === "results" &&
|
||||
{!nav.view && !nav.q && <Welcome onPick={doSearch} recent={snap?.jobs ?? []} />}
|
||||
|
||||
{!nav.view &&
|
||||
nav.q &&
|
||||
(results === null ? (
|
||||
<Skeletons />
|
||||
<SkeletonRows n={6} />
|
||||
) : results.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">
|
||||
No matches. Try adding the artist name.
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={SearchX}
|
||||
title="No matches"
|
||||
body="Try adding the artist name, or fewer words."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
{results.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
@@ -324,71 +196,27 @@ function Muse() {
|
||||
onQueued={refresh}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{view.k === "album" && (
|
||||
<AlbumView id={view.id} onArtist={openArtist} onQueued={refresh} />
|
||||
)}
|
||||
{view.k === "artist" && (
|
||||
<ArtistView id={view.id} onAlbum={openAlbum} onQueued={refresh} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{tab === "review" && <Review onDecided={refresh} />}
|
||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||
{nav.tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||
{nav.tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||
{nav.tab === "review" && <Review onDecided={refresh} />}
|
||||
{nav.tab === "bulk" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 28 }}
|
||||
className="px-5 py-14 text-center"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -7, 0] }}
|
||||
transition={{ duration: 4.5, repeat: Infinity, ease: "easeInOut" }}
|
||||
className="mx-auto mb-4 grid h-16 w-16 place-items-center rounded-[20px]
|
||||
border border-accent-line bg-accent-soft text-accent"
|
||||
>
|
||||
<Disc3 className="h-7 w-7" />
|
||||
</motion.div>
|
||||
<h2 className="mb-2 text-display">Find anything</h2>
|
||||
<p className="mx-auto max-w-[46ch] text-body text-dim">
|
||||
Search a song and press{" "}
|
||||
<kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink">
|
||||
Enter
|
||||
</kbd>
|
||||
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
||||
your library, tagged.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
{EXAMPLES.map((e, i) => (
|
||||
<motion.button
|
||||
key={e}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + i * 0.05 }}
|
||||
whileHover={{ y: -2 }}
|
||||
onClick={() => onPick(e)}
|
||||
className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim
|
||||
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
||||
>
|
||||
{e}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<TabBar nav={nav} go={go} badges={badges} visible={visible} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { api, bytes, type Activity, type AdminUser, type SystemStats } from "@/l
|
||||
import { StatCard } from "@/components/admin/stat-card";
|
||||
import { ActivityChart } from "@/components/admin/activity-chart";
|
||||
import { UsersTable } from "@/components/admin/users-table";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonCards } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AdminPanel({ me }: { me: string | null }) {
|
||||
@@ -32,7 +32,7 @@ export function AdminPanel({ me }: { me: string | null }) {
|
||||
}, [loadUsers]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!sys || !users) return <Skeletons n={6} />;
|
||||
if (!sys || !users) return <SkeletonCards n={4} />;
|
||||
|
||||
const s = sys.system;
|
||||
const lib = s.disks.library;
|
||||
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout",
|
||||
"card flex items-center gap-2.5 rounded-card px-4 py-2.5 text-callout",
|
||||
healthy ? "border-ok/30" : "border-err/40",
|
||||
)}
|
||||
>
|
||||
@@ -141,7 +141,7 @@ function Breakdown({
|
||||
}) {
|
||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.slice(0, 8).map((r, i) => (
|
||||
|
||||
@@ -26,7 +26,7 @@ export function StatCard({
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||
className="glass rounded-glass p-3.5"
|
||||
className="card rounded-card p-3.5"
|
||||
>
|
||||
<div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div>
|
||||
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||
|
||||
@@ -22,7 +22,7 @@ export function UsersTable({
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-panel p-5">
|
||||
<div className="card rounded-panel p-5">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
||||
@@ -35,7 +35,7 @@ export function UsersTable({
|
||||
anyone in — the row is.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
<Button size="pill" variant={adding ? "secondary" : "primary"} onClick={() => setAdding(!adding)}>
|
||||
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{adding ? "Cancel" : "Invite"}
|
||||
</Button>
|
||||
@@ -101,7 +101,7 @@ function UserRow({
|
||||
exit={{ opacity: 0, scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
||||
className={cn(
|
||||
"glass-hi rounded-glass p-3",
|
||||
"card rounded-card p-3",
|
||||
u.disabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
@@ -324,7 +324,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
};
|
||||
|
||||
const input =
|
||||
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"rounded-card border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
||||
"text-ink outline-none focus:border-accent-line";
|
||||
|
||||
return (
|
||||
@@ -334,7 +334,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
||||
<div className="card mt-1 flex flex-wrap items-end gap-2.5 rounded-card p-3">
|
||||
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
||||
<span className="text-caption text-dim">
|
||||
{provider === "github" ? "GitHub username" : "Google email"}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { api, mmss, type AlbumHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { AlertCircle, Disc } from "lucide-react";
|
||||
|
||||
export function AlbumView({
|
||||
id,
|
||||
@@ -43,8 +44,16 @@ export function AlbumView({
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load album: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (err)
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
tone="err"
|
||||
title="Could not load album"
|
||||
body={err}
|
||||
/>
|
||||
);
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { album, tracks } = data;
|
||||
|
||||
@@ -88,6 +97,10 @@ export function AlbumView({
|
||||
album.duration ? mmss(album.duration * 1000) : null,
|
||||
]}
|
||||
actions={
|
||||
// Hidden rather than rendered as "Get all 0 tracks": Deezer serves
|
||||
// metadata for albums whose track list it has no data for, and an
|
||||
// enabled button that queues nothing is worse than no button.
|
||||
tracks.length > 0 ? (
|
||||
<Button onClick={getAll} disabled={batch !== "idle"}>
|
||||
{batch === "idle"
|
||||
? `Get all ${tracks.length} tracks`
|
||||
@@ -95,10 +108,18 @@ export function AlbumView({
|
||||
? "Queueing…"
|
||||
: `Queued ${queued}`}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
{tracks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Disc}
|
||||
title="Deezer has no track list for this release"
|
||||
body="Its catalogue has the album but not its songs. Search for a track by name instead."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
@@ -110,7 +131,8 @@ export function AlbumView({
|
||||
onQueued={onQueued}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { api, type AlbumCard, type ArtistHeader, type Track } from "@/lib/api";
|
||||
import { tint } from "@/lib/colour";
|
||||
import { Hero } from "@/components/hero";
|
||||
import { TrackRow } from "@/components/track-row";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonRows } from "@/components/ui/skeleton";
|
||||
|
||||
export function ArtistView({
|
||||
id,
|
||||
@@ -42,7 +42,7 @@ export function ArtistView({
|
||||
}, [id]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">Could not load artist: {err}</div>;
|
||||
if (!data) return <Skeletons n={8} />;
|
||||
if (!data) return <SkeletonRows n={8} />;
|
||||
|
||||
const { artist, tracks, albums } = data;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function ArtistView({
|
||||
/>
|
||||
|
||||
<SectionHead>Top tracks</SectionHead>
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<motion.div layout className="flex flex-col gap-row">
|
||||
{tracks.map((t, i) => (
|
||||
<TrackRow
|
||||
key={t.deezer_id}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Colour-reactive backdrop. Three blurred blobs tinted from --a-r/g/b, which
|
||||
* the artwork sets, so the page takes on the palette of whatever is on screen.
|
||||
*
|
||||
* Kept faint on purpose: at full strength the gradient competes with the album
|
||||
* art it is derived from, and the glass rows lose contrast against it.
|
||||
*/
|
||||
export function Aurora() {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 -z-20 overflow-hidden pointer-events-none">
|
||||
<motion.i
|
||||
className="absolute block rounded-full bg-accent opacity-30 blur-[110px]
|
||||
w-[60vw] h-[60vw] -top-[22vw] -left-[12vw]"
|
||||
animate={{ x: ["0vw", "8vw", "0vw"], y: ["0vh", "6vh", "0vh"], scale: [1, 1.15, 1] }}
|
||||
transition={{ duration: 26, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-30 blur-[110px]
|
||||
w-[46vw] h-[46vw] top-[18vh] -right-[14vw]
|
||||
bg-[rgb(var(--a-r)_var(--a-g)_var(--a-b)/0.75)]"
|
||||
animate={{ x: ["0vw", "-7vw", "0vw"], y: ["0vh", "10vh", "0vh"], scale: [1, 0.88, 1] }}
|
||||
transition={{ duration: 32, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
{/* Channel-rotated so the palette has range rather than one flat wash,
|
||||
but faint — a full-strength complement fights the artwork. */}
|
||||
<motion.i
|
||||
className="absolute block rounded-full opacity-[0.14] blur-[110px]
|
||||
w-[52vw] h-[52vw] -bottom-[24vw] left-[22vw]
|
||||
bg-[rgb(var(--a-b)_var(--a-r)_var(--a-g)/0.7)]"
|
||||
animate={{ x: ["0vw", "5vw", "0vw"], y: ["0vh", "-8vh", "0vh"], scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 38, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grain fixed inset-0 -z-10 pointer-events-none opacity-[0.16]" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-5"
|
||||
className="card rounded-panel p-5"
|
||||
>
|
||||
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
|
||||
<p className="mb-3.5 max-w-[62ch] text-callout text-dim">
|
||||
@@ -61,7 +61,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={PLACEHOLDER}
|
||||
className="min-h-[170px] w-full resize-y rounded-glass border border-edge
|
||||
className="min-h-[170px] w-full resize-y rounded-card border border-edge
|
||||
bg-black/20 dark:bg-black/20 p-3.5 font-mono text-[13px] leading-[1.7]
|
||||
text-ink outline-none transition
|
||||
placeholder:text-dim/65
|
||||
|
||||
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
||||
import { CalendarDays, Compass, Sparkles, User } from "lucide-react";
|
||||
|
||||
import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { SkeletonGrid } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Recommendations and a personal recap, both derived from muse's OWN request
|
||||
@@ -24,7 +24,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
api.recap().then(setR).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
if (!d) return <Skeletons n={4} />;
|
||||
if (!d) return <SkeletonGrid n={8} />;
|
||||
|
||||
const empty = d.artists.length === 0 && (!r || r.tracks === 0);
|
||||
if (empty)
|
||||
@@ -67,7 +67,7 @@ export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: Math.min(i * 0.03, 0.3) }}
|
||||
onClick={() => onArtist(a.name)}
|
||||
className="glass lift ring group cursor-pointer rounded-panel p-3 text-center"
|
||||
className="card lift focus-ring group cursor-pointer rounded-panel p-3 text-center"
|
||||
>
|
||||
{a.picture ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
@@ -175,7 +175,7 @@ function Card({ title, children }: { title: string; children: React.ReactNode })
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass rounded-panel p-4"
|
||||
className="card rounded-panel p-4"
|
||||
>
|
||||
<h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim">
|
||||
{title}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function Hero({
|
||||
initial={{ opacity: 0, y: 14 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="glass relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
className="card relative mb-5 flex flex-col sm:flex-row items-center sm:items-stretch
|
||||
gap-4 sm:gap-5 overflow-hidden rounded-panel p-5 text-center sm:text-left"
|
||||
>
|
||||
{art && (
|
||||
@@ -72,7 +72,7 @@ export function Hero({
|
||||
{facts.filter(Boolean).map((f, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
className="card rounded-full px-2.5 py-0.5 text-caption text-dim"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckCircle2, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
import { Sheet } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useToast } from "@/components/toast";
|
||||
|
||||
/**
|
||||
* Why a request is where it is.
|
||||
*
|
||||
* Previously a failure was a red word in a list plus a three-second toast, and
|
||||
* the reason was raw text — "transfer Completed, Errored" — joined with a dot
|
||||
* after the album name in 13px dim type. Since the backend now walks a ladder
|
||||
* of candidates instead of giving up on the first peer, there is an actual
|
||||
* story to tell: who was tried, what each one said, and what is left.
|
||||
*/
|
||||
export function JobSheet({
|
||||
job,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
job: Job | null;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
const retry = async () => {
|
||||
if (!job) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.retry(job.id);
|
||||
toast("Requeued");
|
||||
onChanged();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, true);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const conf = job?.confidence;
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={!!job}
|
||||
onOpenChange={(v) => !v && onClose()}
|
||||
title={job ? job.title : ""}
|
||||
description={job ? [job.artist, job.album].filter(Boolean).join(" · ") : undefined}
|
||||
footer={
|
||||
job?.state === "failed" ? (
|
||||
<Button onClick={retry} disabled={busy}>
|
||||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null} Try again
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{job && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatePill state={job.state} />
|
||||
{job.attempts > 0 && (
|
||||
<span className="text-caption text-mute nums">
|
||||
{job.attempts} peer{job.attempts === 1 ? "" : "s"} tried
|
||||
{job.state !== "failed" && job.remaining > 0
|
||||
? ` · ${job.remaining} left to try`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.detail && (
|
||||
<p className="text-callout text-dim">{job.detail}</p>
|
||||
)}
|
||||
|
||||
{conf != null && (
|
||||
<section>
|
||||
<div className="mb-1.5 flex items-baseline justify-between">
|
||||
<h3 className="text-callout font-medium">Match confidence</h3>
|
||||
<span className="text-callout text-dim nums">{Math.round(conf * 100)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={conf * 100}
|
||||
tone={conf >= 0.95 ? "ok" : conf >= 0.7 ? "warn" : "err"}
|
||||
/>
|
||||
{job.match_why && (
|
||||
<p className="mt-1.5 text-caption text-mute">{job.match_why}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.remote_file && (
|
||||
<section>
|
||||
<h3 className="mb-1.5 text-callout font-medium">File</h3>
|
||||
<p className="break-all rounded-control bg-[var(--surface-2)] p-2.5 font-mono text-caption text-dim">
|
||||
{job.remote_file.replace(/\\/g, "/")}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{job.tried.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-callout font-medium">
|
||||
Peers tried
|
||||
</h3>
|
||||
<ol className="flex flex-col gap-1.5">
|
||||
{job.tried.map((t, i) => (
|
||||
<li
|
||||
key={`${t.username}-${i}`}
|
||||
className="flex items-start gap-2 rounded-control border border-edge
|
||||
bg-[var(--surface-2)] px-2.5 py-2"
|
||||
>
|
||||
<CircleSlash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-err" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{t.username}</div>
|
||||
<div className="text-caption text-dim">{t.reason}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{job.state === "downloading" && job.username && (
|
||||
<li
|
||||
className="flex items-start gap-2 rounded-control border border-accent-line
|
||||
bg-accent-soft px-2.5 py-2"
|
||||
>
|
||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-accent" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-callout font-medium">{job.username}</div>
|
||||
<div className="text-caption text-dim">downloading now</div>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StatePill({ state }: { state: Job["state"] }) {
|
||||
const map = {
|
||||
imported: { tone: "ok", icon: CheckCircle2, label: "In your library" },
|
||||
failed: { tone: "err", icon: XCircle, label: "Failed" },
|
||||
review: { tone: "accent", icon: CheckCircle2, label: "Waiting on you" },
|
||||
downloading: { tone: "warn", icon: Loader2, label: "Downloading" },
|
||||
searching: { tone: "warn", icon: Loader2, label: "Searching Soulseek" },
|
||||
queued: { tone: "neutral", icon: Loader2, label: "Queued" },
|
||||
} as const;
|
||||
const m = map[state];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<Badge tone={m.tone}>
|
||||
<Icon className={`h-3 w-3 ${state === "imported" || state === "failed" ? "" : "animate-spin"}`} />
|
||||
{m.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
onClick={stop}
|
||||
aria-label="Stop preview"
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full glass-hi
|
||||
className="w-8 h-8 shrink-0 grid place-items-center rounded-full card
|
||||
text-ink cursor-pointer transition hover:border-accent-line
|
||||
hover:text-accent"
|
||||
>
|
||||
|
||||
@@ -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 { AnimatePresence, motion } from "framer-motion";
|
||||
import { api, type Job } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox, ChevronRight } from "lucide-react";
|
||||
import { type Job } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { JobSheet } from "@/components/job-sheet";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type JobsSnapshot = {
|
||||
@@ -13,88 +18,89 @@ export type JobsSnapshot = {
|
||||
pending: number;
|
||||
};
|
||||
|
||||
const PILL: Record<Job["state"], string> = {
|
||||
imported: "text-ok border-ok/40 bg-ok/10",
|
||||
failed: "text-err border-err/40 bg-err/10",
|
||||
// Held for review, not in flight and not finished — it needs a person, so
|
||||
// it reads as the accent rather than as another in-progress amber.
|
||||
review: "text-accent border-accent-line bg-accent-soft",
|
||||
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||
searching: "text-warn border-warn/40 bg-warn/10",
|
||||
queued: "text-warn border-warn/40 bg-warn/10",
|
||||
/**
|
||||
* `queued`, `searching` and `downloading` used to be the same amber pill and
|
||||
* differed only by the word inside, so the three stages of a request looked
|
||||
* identical at a glance. They are distinct stages with distinct waits, so they
|
||||
* now read as distinct.
|
||||
*/
|
||||
const STATE: Record<Job["state"], { tone: "neutral" | "accent" | "ok" | "warn" | "err"; label: string; live?: boolean }> = {
|
||||
queued: { tone: "neutral", label: "Queued" },
|
||||
searching: { tone: "accent", label: "Searching", live: true },
|
||||
downloading: { tone: "warn", label: "Downloading", live: true },
|
||||
review: { tone: "accent", label: "Needs review" },
|
||||
imported: { tone: "ok", label: "In library" },
|
||||
failed: { tone: "err", label: "Failed" },
|
||||
};
|
||||
|
||||
export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh: () => void }) {
|
||||
const toast = useToast();
|
||||
const ok = useMotionOK();
|
||||
const [open, setOpen] = React.useState<string | null>(null);
|
||||
if (!snap) return null;
|
||||
|
||||
const { jobs, counts, pending } = snap;
|
||||
const { jobs, counts } = snap;
|
||||
const active = (counts.queued ?? 0) + (counts.searching ?? 0) + (counts.downloading ?? 0);
|
||||
const done = counts.imported ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const held = counts.review ?? 0;
|
||||
const total = active + done;
|
||||
|
||||
const held = counts.review ?? 0;
|
||||
const stats: [string, number, string][] = [
|
||||
["In flight", active, "text-accent"],
|
||||
["Imported", done, "text-ok"],
|
||||
["Held", held, held > 0 ? "text-warn" : ""],
|
||||
["Failed", failed, "text-err"],
|
||||
["In library", done, "text-ok"],
|
||||
["Needs review", held, held > 0 ? "text-warn" : "text-dim"],
|
||||
["Failed", failed, failed > 0 ? "text-err" : "text-dim"],
|
||||
];
|
||||
|
||||
// Kept in sync with the poll so the sheet updates live while it is open.
|
||||
const current = jobs.find((j) => j.id === open) ?? null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
<div className="mb-4 grid grid-cols-2 gap-2.5 sm:grid-cols-4">
|
||||
{stats.map(([label, n, colour]) => (
|
||||
<motion.div key={label} layout className="glass rounded-glass p-3.5">
|
||||
<motion.b
|
||||
key={n}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn("block text-title leading-none nums", colour)}
|
||||
>
|
||||
{n}
|
||||
</motion.b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim">
|
||||
<Card key={label} pad="sm">
|
||||
<b className={cn("block text-title leading-none nums", colour)}>{n}</b>
|
||||
<span className="mt-1 block text-caption uppercase tracking-[0.06em] text-mute">
|
||||
{label}
|
||||
</span>
|
||||
</motion.div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{active > 0 && total > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 4 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4 overflow-hidden rounded-full bg-faint"
|
||||
>
|
||||
<motion.i
|
||||
className="block h-1 rounded-full bg-accent"
|
||||
animate={{ width: `${(done / total) * 100}%` }}
|
||||
transition={{ ease: "easeOut", duration: 0.5 }}
|
||||
<Progress
|
||||
value={(done / total) * 100}
|
||||
className="mb-4"
|
||||
label="Requests completed"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-dim">Nothing requested yet.</div>
|
||||
<EmptyState
|
||||
icon={Inbox}
|
||||
title="Nothing requested yet"
|
||||
body="Search for a song and press Get. Anything you ask for shows up here with its progress."
|
||||
/>
|
||||
) : (
|
||||
<motion.div layout className="flex flex-col gap-[7px]">
|
||||
<div className="flex flex-col gap-row">
|
||||
<AnimatePresence initial={false}>
|
||||
{jobs.map((j) => (
|
||||
<motion.div
|
||||
{jobs.map((j) => {
|
||||
const s = STATE[j.state] ?? STATE.queued;
|
||||
return (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 10 } : false}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 34 }}
|
||||
className="glass flex items-center gap-3 rounded-glass py-2.5 pl-2.5 pr-3"
|
||||
exit={ok ? { opacity: 0, scale: 0.97 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
onClick={() => setOpen(j.id)}
|
||||
aria-label={`${j.title} by ${j.artist} — ${s.label}. Show details`}
|
||||
className="card focus-ring flex items-center gap-3 rounded-card py-2.5 pl-2.5 pr-2
|
||||
text-left transition-colors hover:bg-[var(--surface-2)]"
|
||||
>
|
||||
<div className="h-[50px] w-[50px] shrink-0 overflow-hidden rounded-[9px] bg-faint">
|
||||
<div className="h-[48px] w-[48px] shrink-0 overflow-hidden rounded-control bg-[var(--surface-3)]">
|
||||
{j.cover && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
@@ -110,45 +116,32 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-body font-semibold clamp1">{j.title}</div>
|
||||
<div className="mt-px text-callout text-dim clamp1">
|
||||
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||
{[j.artist, j.album].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
{j.detail && j.state === "failed" && (
|
||||
<div className="mt-0.5 text-caption text-err clamp1">{j.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{j.state === "failed" && (
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await api.retry(j.id);
|
||||
toast("Requeued");
|
||||
refresh();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
||||
"text-caption font-semibold capitalize",
|
||||
PILL[j.state] ?? "text-dim border-edge",
|
||||
)}
|
||||
>
|
||||
{(j.state === "searching" || j.state === "downloading") && (
|
||||
<Badge tone={s.tone} className="shrink-0">
|
||||
{s.live && (
|
||||
<motion.span
|
||||
className="mr-1.5 inline-block h-[5px] w-[5px] rounded-full bg-current align-[1px]"
|
||||
animate={{ opacity: [1, 0.25, 1] }}
|
||||
className="h-[5px] w-[5px] rounded-full bg-current"
|
||||
animate={ok ? { opacity: [1, 0.25, 1] } : {}}
|
||||
transition={{ duration: 1.3, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
{j.state}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{s.label}
|
||||
</Badge>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-mute" />
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JobSheet job={current} onClose={() => setOpen(null)} onChanged={refresh} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Check, FileAudio, ShieldQuestion, X } from "lucide-react";
|
||||
import { api, bytes, type ReviewItem } from "@/lib/api";
|
||||
import { useToast } from "@/components/toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeletons } from "@/components/skeletons";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -36,7 +36,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
}, [load]);
|
||||
|
||||
if (err) return <div className="py-12 text-center text-dim">{err}</div>;
|
||||
if (!items) return <Skeletons n={3} />;
|
||||
if (!items) return <div className="flex flex-col gap-3">{[0,1,2].map((i)=>(<Skeleton key={i} className="h-[196px] rounded-panel" />))}</div>;
|
||||
|
||||
if (items.length === 0)
|
||||
return (
|
||||
@@ -47,7 +47,7 @@ export function Review({ onDecided }: { onDecided: () => void }) {
|
||||
>
|
||||
<div
|
||||
className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px]
|
||||
border border-edge bg-[var(--glass)] text-dim"
|
||||
border border-edge bg-[var(--surface)] text-dim"
|
||||
>
|
||||
<Check className="h-6 w-6" />
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ function ReviewCard({
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 30 }}
|
||||
className="glass rounded-panel p-4 sm:p-5"
|
||||
className="card rounded-panel p-4 sm:p-5"
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{item.cover ? (
|
||||
@@ -123,11 +123,11 @@ function ReviewCard({
|
||||
<img
|
||||
src={item.cover}
|
||||
alt=""
|
||||
className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover"
|
||||
className="h-16 w-16 shrink-0 rounded-card border border-edge object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-glass
|
||||
className="grid h-16 w-16 shrink-0 place-items-center rounded-card
|
||||
border border-edge bg-faint text-dim"
|
||||
>
|
||||
<FileAudio className="h-6 w-6" />
|
||||
@@ -145,7 +145,7 @@ function ReviewCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3">
|
||||
<div className="mt-3.5 rounded-card border border-edge bg-[var(--surface)] p-3">
|
||||
<div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim">
|
||||
<ShieldQuestion className="h-3 w-3" /> what was found
|
||||
</div>
|
||||
|
||||
@@ -9,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 };
|
||||
|
||||
export function SearchBar({
|
||||
initial,
|
||||
busy,
|
||||
onSearch,
|
||||
onAlbum,
|
||||
onArtist,
|
||||
}: {
|
||||
/** Seeds the field from the URL, so a shared link shows what it searched. */
|
||||
initial?: string;
|
||||
busy?: boolean;
|
||||
onSearch: (q: string) => void;
|
||||
onAlbum: (id: number) => void;
|
||||
onArtist: (id: number) => void;
|
||||
}) {
|
||||
const [q, setQ] = React.useState("");
|
||||
const [q, setQ] = React.useState(initial ?? "");
|
||||
const [items, setItems] = React.useState<Item[]>([]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [sel, setSel] = React.useState(-1);
|
||||
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
|
||||
// AbortController matters more than the delay: without it a slow response
|
||||
@@ -76,7 +95,7 @@ export function SearchBar({
|
||||
];
|
||||
setItems(next);
|
||||
setSel(-1);
|
||||
setOpen(next.length > 0);
|
||||
setOpen(typed.current && next.length > 0);
|
||||
} catch {
|
||||
/* aborted or offline — leave the previous list alone */
|
||||
}
|
||||
@@ -129,9 +148,9 @@ export function SearchBar({
|
||||
<div className="relative z-50">
|
||||
<div
|
||||
className={cn(
|
||||
"glass flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-glass",
|
||||
"focus-within:border-accent-line focus-within:bg-glass-hi",
|
||||
"card flex items-center gap-3 h-[50px] px-3.5 rounded-[20px]",
|
||||
"transition-all duration-250 ease-out",
|
||||
"focus-within:border-accent-line focus-within:bg-[var(--surface-2)]",
|
||||
"focus-within:shadow-[0_0_0_4px_var(--color-accent-soft)]",
|
||||
)}
|
||||
>
|
||||
@@ -140,7 +159,10 @@ export function SearchBar({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={(e) => {
|
||||
typed.current = true;
|
||||
setQ(e.target.value);
|
||||
}}
|
||||
onKeyDown={key}
|
||||
onFocus={() => items.length && setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||
@@ -161,7 +183,7 @@ export function SearchBar({
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="w-6.5 h-6.5 shrink-0 grid place-items-center rounded-full
|
||||
glass-hi text-dim hover:text-ink cursor-pointer"
|
||||
card text-dim hover:text-ink cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -43,14 +43,14 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{anonymous && (
|
||||
<div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||
<div className="card rounded-panel border-warn/40 p-4 text-callout text-warn">
|
||||
Authentication is turned off on this instance, so there is no account to
|
||||
save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "}
|
||||
to <code className="font-mono text-[12px]">github</code> or{" "}
|
||||
<code className="font-mono text-[12px]">google</code> to enable sign-in.
|
||||
</div>
|
||||
)}
|
||||
<section className="glass rounded-panel p-5">
|
||||
<section className="card rounded-panel p-5">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 text-accent" />
|
||||
<h2 className="text-headline">Notifications</h2>
|
||||
@@ -61,7 +61,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</p>
|
||||
|
||||
{!me.mail_configured && (
|
||||
<div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn">
|
||||
<div className="mb-4 rounded-card border border-warn/40 bg-[var(--surface)] p-3 text-callout text-warn">
|
||||
No SMTP server is configured on this instance, so nothing will actually be
|
||||
sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "}
|
||||
<code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on.
|
||||
@@ -79,7 +79,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
onKeyDown={(e) => e.key === "Enter" && dirty && save()}
|
||||
disabled={anonymous}
|
||||
placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"}
|
||||
className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
||||
className="focus-ring rounded-control border border-edge-hi bg-transparent px-3 py-2
|
||||
text-body outline-none transition focus:border-accent-line
|
||||
disabled:opacity-45"
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
role="switch"
|
||||
aria-checked={notify}
|
||||
disabled={anonymous}
|
||||
className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass
|
||||
className="focus-ring mt-4 flex cursor-pointer items-center gap-3 rounded-card
|
||||
text-left disabled:cursor-default disabled:opacity-45"
|
||||
>
|
||||
<span
|
||||
@@ -130,7 +130,7 @@ export function Settings({ me, refresh }: { me: Me; refresh: () => void }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="glass rounded-panel p-5">
|
||||
<section className="card rounded-panel p-5">
|
||||
<h2 className="mb-3 text-headline">Account</h2>
|
||||
{anonymous ? (
|
||||
<p className="text-callout text-dim">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
ListMusic,
|
||||
ShieldCheck,
|
||||
ClipboardList,
|
||||
Settings2,
|
||||
Gauge,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { Tab } from "@/lib/nav";
|
||||
|
||||
export type NavItem = {
|
||||
tab: Tab;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
group: "Library" | "Activity" | "Manage";
|
||||
};
|
||||
|
||||
/**
|
||||
* Grouped, because a flat list of seven is a menu and a grouped list of seven
|
||||
* is a structure. The order is the order of use: you search, you watch what
|
||||
* you asked for, you administer occasionally.
|
||||
*/
|
||||
export const NAV: NavItem[] = [
|
||||
{ tab: "search", label: "Search", icon: Search, group: "Library" },
|
||||
{ tab: "discover", label: "Discover", icon: Sparkles, group: "Library" },
|
||||
{ tab: "jobs", label: "Requests", icon: ListMusic, group: "Activity" },
|
||||
{ tab: "review", label: "Review", icon: ShieldCheck, group: "Activity" },
|
||||
{ tab: "bulk", label: "Import", icon: ClipboardList, group: "Manage" },
|
||||
{ tab: "settings", label: "Settings", icon: Settings2, group: "Manage" },
|
||||
{ tab: "admin", label: "Admin", icon: Gauge, group: "Manage" },
|
||||
];
|
||||
|
||||
/** The five that earn a slot on a phone's bottom bar. */
|
||||
export const MOBILE: Tab[] = ["search", "discover", "jobs", "review", "settings"];
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Music2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
import { NAV } from "./nav-items";
|
||||
|
||||
/**
|
||||
* Sidebar navigation, the pattern Apple uses in Music, Mail, Finder and
|
||||
* Settings. It replaces a horizontally scrolling row of pills, which is a
|
||||
* mobile-web affordance that had been left on the desktop layout too.
|
||||
*
|
||||
* Collapses to a 68px icon rail between 768 and 1024, where a 240px sidebar
|
||||
* would eat a third of the window.
|
||||
*/
|
||||
export function Sidebar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
rail,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
rail: boolean;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const groups = ["Library", "Activity", "Manage"] as const;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className={cn(
|
||||
"material sticky top-0 hidden h-dvh shrink-0 flex-col gap-1 border-r border-edge",
|
||||
"md:flex",
|
||||
rail ? "w-[var(--rail-w)] items-center px-2 py-3" : "w-[var(--sidebar-w)] px-3 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("mb-4 flex items-center gap-2.5", rail ? "justify-center" : "px-2")}>
|
||||
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-card bg-accent text-[var(--accent-ink)]">
|
||||
<Music2 className="h-4 w-4" />
|
||||
</span>
|
||||
{!rail && <span className="text-headline tracking-tight">muse</span>}
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const items = NAV.filter((n) => n.group === g && visible(n.tab));
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={g} className="mb-2 w-full">
|
||||
{!rail && (
|
||||
<h2 className="mb-1 px-2 text-caption font-semibold uppercase tracking-[0.06em] text-mute">
|
||||
{g}
|
||||
</h2>
|
||||
)}
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<li key={tab}>
|
||||
<a
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
title={rail ? label : undefined}
|
||||
onClick={(e) => {
|
||||
// Left-click navigates in place; cmd/ctrl-click still
|
||||
// opens a real new tab, because these are real links.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex items-center rounded-control text-callout",
|
||||
"font-medium transition-colors duration-150",
|
||||
rail ? "h-9 w-9 justify-center" : "gap-2.5 px-2 py-1.5",
|
||||
on ? "text-ink" : "text-dim hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
{on && (
|
||||
<motion.span
|
||||
layoutId="nav-selection"
|
||||
transition={ok ? spring : { duration: 0 }}
|
||||
className="absolute inset-0 rounded-control bg-[var(--surface-3)]"
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative h-4 w-4 shrink-0" />
|
||||
{!rail && <span className="relative flex-1 clamp1">{label}</span>}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-pill text-caption font-bold nums",
|
||||
rail
|
||||
? "absolute -right-1 -top-1 h-4 min-w-4 px-1 text-center leading-4"
|
||||
: "relative px-1.5",
|
||||
tab === "review"
|
||||
? "bg-warn/20 text-warn"
|
||||
: "bg-accent-soft text-accent",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertTriangle, WifiOff } from "lucide-react";
|
||||
import { api, type Health } from "@/lib/api";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* System health, for everyone.
|
||||
*
|
||||
* This check already existed but lived inside the admin panel, so a regular
|
||||
* user whose every request was failing saw nothing but a growing pile of red
|
||||
* pills. "Reachable but not logged in" is the important case: the container is
|
||||
* up and healthy, so no other signal reports a problem, yet every search
|
||||
* returns nothing.
|
||||
*/
|
||||
export function StatusBanner() {
|
||||
const [h, setH] = React.useState<Health | null>(null);
|
||||
const ok = useMotionOK();
|
||||
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await api.health();
|
||||
if (live) setH(r);
|
||||
} catch {
|
||||
/* a 401 here just means the poll outlived the session */
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const t = setInterval(poll, 20000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const problem = !h
|
||||
? null
|
||||
: h.slskd !== 200
|
||||
? {
|
||||
icon: WifiOff,
|
||||
text: "Can't reach slskd — nothing will download until it's back.",
|
||||
}
|
||||
: h.logged_in === false
|
||||
? {
|
||||
icon: AlertTriangle,
|
||||
text: "slskd is running but not signed in to Soulseek — searches will return nothing.",
|
||||
}
|
||||
: h.library_writable === false
|
||||
? { icon: AlertTriangle, text: "The music library isn't writable — imports will fail." }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{problem && (
|
||||
<motion.div
|
||||
role="status"
|
||||
initial={ok ? { height: 0, opacity: 0 } : false}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={ok ? { height: 0, opacity: 0 } : { opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-warn/30 bg-warn/10 px-4 py-2 text-callout text-warn">
|
||||
<problem.icon className="h-4 w-4 shrink-0" />
|
||||
<span className="clamp1">{problem.text}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { href, type Nav, type Tab } from "@/lib/nav";
|
||||
import { NAV, MOBILE } from "./nav-items";
|
||||
|
||||
/**
|
||||
* The phone layout. A bottom bar is what a native app does, and it puts the
|
||||
* targets under the thumb instead of at the top of a tall screen. Respects the
|
||||
* home-indicator inset, which the previous layout did not.
|
||||
*/
|
||||
export function TabBar({
|
||||
nav,
|
||||
go,
|
||||
badges,
|
||||
visible,
|
||||
}: {
|
||||
nav: Nav;
|
||||
go: (n: Nav) => void;
|
||||
badges: Partial<Record<Tab, number>>;
|
||||
visible: (t: Tab) => boolean;
|
||||
}) {
|
||||
const items = NAV.filter((n) => MOBILE.includes(n.tab) && visible(n.tab));
|
||||
return (
|
||||
<nav
|
||||
aria-label="Sections"
|
||||
className="material-hi fixed inset-x-0 bottom-0 z-40 flex border-t border-edge
|
||||
pb-[env(safe-area-inset-bottom)] md:hidden"
|
||||
>
|
||||
{items.map(({ tab, label, icon: Icon }) => {
|
||||
const on = nav.tab === tab;
|
||||
const badge = badges[tab] ?? 0;
|
||||
return (
|
||||
<a
|
||||
key={tab}
|
||||
href={href({ tab })}
|
||||
aria-current={on ? "page" : undefined}
|
||||
onClick={(e) => {
|
||||
if (e.metaKey || e.ctrlKey) return;
|
||||
e.preventDefault();
|
||||
go({ tab });
|
||||
}}
|
||||
className={cn(
|
||||
"focus-ring relative flex flex-1 flex-col items-center gap-0.5 py-2",
|
||||
"text-caption font-medium transition-colors",
|
||||
on ? "text-accent" : "text-mute",
|
||||
)}
|
||||
>
|
||||
<span className="relative">
|
||||
<Icon className="h-5 w-5" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -right-2 -top-1 min-w-[15px] rounded-pill px-1 text-[9px]",
|
||||
"font-bold leading-[15px] nums",
|
||||
tab === "review" ? "bg-warn text-black" : "bg-accent text-[var(--accent-ink)]",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Me } from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/**
|
||||
* Sticky chrome over scrolling content: search on the left, identity on the
|
||||
* right. Everything that used to be hidden below `sm:` — the quota chip, the
|
||||
* display name — now degrades into the overflow rather than vanishing, since
|
||||
* "how many requests do I have left" matters most on the device you are
|
||||
* actually queueing from.
|
||||
*/
|
||||
export function Toolbar({
|
||||
me,
|
||||
quotaLeft,
|
||||
children,
|
||||
}: {
|
||||
me: Me | null;
|
||||
quotaLeft: number | null;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="material sticky top-0 z-30 border-b border-edge">
|
||||
<div className="mx-auto flex w-full max-w-[var(--container-3xl)] items-center gap-3 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
|
||||
{me?.user && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{quotaLeft !== null && (
|
||||
<Badge
|
||||
tone={quotaLeft === 0 ? "err" : quotaLeft <= 5 ? "warn" : "neutral"}
|
||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||
className="nums"
|
||||
>
|
||||
{quotaLeft} left
|
||||
</Badge>
|
||||
)}
|
||||
{me.role && me.role !== "user" && (
|
||||
<Badge tone="accent" className="hidden uppercase tracking-[0.06em] sm:inline-flex">
|
||||
{me.role}
|
||||
</Badge>
|
||||
)}
|
||||
{me.avatar && (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={me.avatar}
|
||||
alt=""
|
||||
className="h-7 w-7 rounded-full border border-edge object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="hidden max-w-[14ch] text-callout font-medium clamp1 lg:inline">
|
||||
{me.display || me.user}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
title="Log out"
|
||||
aria-label="Log out"
|
||||
className={cn(
|
||||
"focus-ring grid h-8 w-8 place-items-center rounded-control text-dim",
|
||||
"transition-colors hover:bg-[var(--faint)] hover:text-ink",
|
||||
)}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
/** Shimmering placeholders. Sized to the real row so nothing jumps on load. */
|
||||
export function Skeletons({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-[7px]">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: i * 0.04 }}
|
||||
className="shimmer h-[68px] rounded-glass border border-edge"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+69
-15
@@ -2,43 +2,97 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AlertCircle, CheckCircle2, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK, spring } from "@/lib/motion";
|
||||
|
||||
type Toast = { id: number; msg: string; err?: boolean };
|
||||
type Ctx = (msg: string, err?: boolean) => void;
|
||||
type Toast = { id: number; msg: string; err?: boolean; action?: Action };
|
||||
type Action = { label: string; run: () => void };
|
||||
type Ctx = (msg: string, err?: boolean, action?: Action) => void;
|
||||
|
||||
const ToastCtx = React.createContext<Ctx>(() => {});
|
||||
export const useToast = () => React.useContext(ToastCtx);
|
||||
|
||||
/** An error you cannot read in time is the same as no error at all. */
|
||||
const DWELL_OK = 3400;
|
||||
const DWELL_ERR = 7000;
|
||||
/** Beyond this the stack is a wall, and the oldest is the least relevant. */
|
||||
const MAX = 3;
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [items, setItems] = React.useState<Toast[]>([]);
|
||||
const ok = useMotionOK();
|
||||
|
||||
const push = React.useCallback<Ctx>((msg, err) => {
|
||||
const drop = React.useCallback(
|
||||
(id: number) => setItems((x) => x.filter((t) => t.id !== id)),
|
||||
[],
|
||||
);
|
||||
|
||||
const push = React.useCallback<Ctx>(
|
||||
(msg, err, action) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setItems((x) => [...x, { id, msg, err }]);
|
||||
setTimeout(() => setItems((x) => x.filter((t) => t.id !== id)), 3400);
|
||||
}, []);
|
||||
setItems((x) => [...x, { id, msg, err, action }].slice(-MAX));
|
||||
setTimeout(() => drop(id), err ? DWELL_ERR : DWELL_OK);
|
||||
},
|
||||
[drop],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastCtx.Provider value={push}>
|
||||
{children}
|
||||
<div className="fixed bottom-24 left-1/2 -translate-x-1/2 z-90 flex flex-col items-center gap-2 pointer-events-none">
|
||||
{/*
|
||||
aria-live so a screen reader hears "Queued" or a failure at all — these
|
||||
were previously silent, which made the only confirmation that a request
|
||||
had been accepted a purely visual one.
|
||||
*/}
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed inset-x-0 bottom-24 z-[110] flex flex-col
|
||||
items-center gap-2 px-4 md:bottom-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{items.map((t) => (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 18, scale: 0.94 }}
|
||||
layout={ok}
|
||||
initial={ok ? { opacity: 0, y: 18, scale: 0.94 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||
exit={ok ? { opacity: 0, y: 10, scale: 0.96 } : { opacity: 0 }}
|
||||
transition={spring}
|
||||
className={cn(
|
||||
"sheet rounded-full px-5 py-2.5 text-callout font-medium text-center",
|
||||
"max-w-[calc(100vw-32px)]",
|
||||
t.err && "border-err/50 text-err",
|
||||
"sheet pointer-events-auto flex max-w-[min(30rem,100%)] items-center gap-2.5",
|
||||
"rounded-pill py-2 pl-3.5 pr-2 text-callout font-medium",
|
||||
t.err && "border-err/45",
|
||||
)}
|
||||
>
|
||||
{t.msg}
|
||||
{t.err ? (
|
||||
<AlertCircle className="h-4 w-4 shrink-0 text-err" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 text-ok" />
|
||||
)}
|
||||
<span className={cn("min-w-0 flex-1", t.err && "text-err")}>{t.msg}</span>
|
||||
{t.action && (
|
||||
<button
|
||||
onClick={() => {
|
||||
t.action!.run();
|
||||
drop(t.id);
|
||||
}}
|
||||
className="focus-ring shrink-0 rounded-pill px-2.5 py-0.5 text-callout
|
||||
font-semibold text-accent transition-colors hover:bg-accent-soft"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => drop(t.id)}
|
||||
aria-label="Dismiss"
|
||||
className="focus-ring grid h-6 w-6 shrink-0 place-items-center rounded-full
|
||||
text-mute transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -73,8 +73,8 @@ export function TrackRow({
|
||||
transition={{ delay: Math.min(index * 0.022, 0.4), type: "spring", stiffness: 380, damping: 34 }}
|
||||
whileHover={{ y: -1 }}
|
||||
className={cn(
|
||||
"glass rounded-glass flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-glass-hi hover:border-edge-hi",
|
||||
"card rounded-card flex items-center gap-3 py-2.5 pl-2.5 pr-3",
|
||||
"transition-colors hover:bg-[var(--surface-2)] hover:border-edge-hi",
|
||||
isPlaying && "border-accent-line bg-accent-soft",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badge = cva(
|
||||
"inline-flex items-center gap-1 rounded-pill border px-2 py-0.5 " +
|
||||
"text-caption font-medium whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: "border-edge text-dim",
|
||||
accent: "border-accent-line bg-accent-soft text-accent",
|
||||
ok: "border-ok/40 bg-ok/10 text-ok",
|
||||
warn: "border-warn/40 bg-warn/10 text-warn",
|
||||
err: "border-err/40 bg-err/10 text-err",
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: "neutral" },
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
tone,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badge>) {
|
||||
return <span className={cn(badge({ tone }), className)} {...props} />;
|
||||
}
|
||||
@@ -6,30 +6,36 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const button = cva(
|
||||
"inline-flex items-center justify-center gap-2 font-semibold cursor-pointer " +
|
||||
"transition-all duration-200 ease-glass disabled:opacity-45 " +
|
||||
"disabled:cursor-default disabled:pointer-events-none select-none " +
|
||||
"focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-accent-soft",
|
||||
"inline-flex items-center justify-center gap-1.5 font-semibold cursor-pointer " +
|
||||
"select-none whitespace-nowrap focus-ring " +
|
||||
"transition-[background,border-color,color,transform,box-shadow] " +
|
||||
"duration-150 ease-out active:scale-[0.97] " +
|
||||
"disabled:opacity-40 disabled:cursor-default disabled:pointer-events-none " +
|
||||
"disabled:active:scale-100",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Solid accent. The label is near-black because the accent is clamped
|
||||
// to a light-ish luminance in lib/colour.ts — the two decisions have
|
||||
// to stay in step or this button becomes unreadable on some albums.
|
||||
primary:
|
||||
"bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " +
|
||||
"active:scale-[0.97] active:brightness-95",
|
||||
ghost:
|
||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
||||
// The accent is fixed now, not sampled from artwork, so a solid button
|
||||
// has a known contrast ratio and can carry a plain white label. It
|
||||
// used to be near-black text on a colour taken from the album cover,
|
||||
// which only worked because colour.ts clamped the luminance.
|
||||
primary: "bg-accent text-[var(--accent-ink)] shadow-[var(--e1)] hover:brightness-110",
|
||||
secondary:
|
||||
"bg-[var(--surface-3)] text-ink border border-edge hover:border-edge-hi " +
|
||||
"hover:bg-[var(--surface-2)]",
|
||||
outline:
|
||||
"bg-transparent border border-edge-hi text-ink hover:border-accent-line " +
|
||||
"hover:bg-accent-soft hover:text-accent active:scale-[0.95]",
|
||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
||||
"bg-transparent border border-edge-hi text-ink " +
|
||||
"hover:border-accent-line hover:bg-accent-soft hover:text-accent",
|
||||
quiet: "bg-transparent text-dim hover:text-ink hover:bg-[var(--faint)]",
|
||||
danger: "bg-transparent border border-err/45 text-err hover:bg-err/10",
|
||||
},
|
||||
size: {
|
||||
sm: "text-caption px-3 py-1.5 rounded-pill",
|
||||
md: "text-callout px-4.5 py-2.5 rounded-control",
|
||||
pill: "text-callout px-4 py-2 rounded-pill",
|
||||
xs: "text-caption px-2.5 py-1 rounded-control gap-1",
|
||||
sm: "text-callout px-3 py-1.5 rounded-control",
|
||||
md: "text-callout px-4 py-2 rounded-control",
|
||||
lg: "text-body px-5 py-2.5 rounded-control font-semibold",
|
||||
icon: "h-8 w-8 rounded-control",
|
||||
pill: "text-callout px-4 py-1.5 rounded-pill",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "primary", size: "md" },
|
||||
@@ -45,9 +51,7 @@ export interface ButtonProps
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />
|
||||
);
|
||||
return <Comp ref={ref} className={cn(button({ variant, size }), className)} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The content surface. `"card rounded-panel p-5"` was copy-pasted across eight
|
||||
* files with the padding drifting between p-3, p-3.5, p-4 and p-5; this fixes
|
||||
* the scale in one place.
|
||||
*/
|
||||
export function Card({
|
||||
className,
|
||||
pad = "md",
|
||||
interactive,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
pad?: "none" | "sm" | "md" | "lg";
|
||||
interactive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"card rounded-panel",
|
||||
{ none: "", sm: "p-3", md: "p-4", lg: "p-5 sm:p-6" }[pad],
|
||||
interactive && "lift cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("mb-3 flex items-center justify-between gap-3", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("text-headline", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardNote({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-callout text-dim", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Seven screens rendered a bare centred string for "nothing here" and two had
|
||||
* a real designed state. This is the designed one, made reusable.
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
tone = "neutral",
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
tone?: "neutral" | "err";
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center px-6 py-14 text-center", className)}>
|
||||
{Icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 grid h-12 w-12 place-items-center rounded-panel border",
|
||||
tone === "err"
|
||||
? "border-err/35 bg-err/10 text-err"
|
||||
: "border-edge bg-[var(--surface-2)] text-mute",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className={cn("text-headline", tone === "err" && "text-err")}>{title}</h3>
|
||||
{body && <p className="mt-1.5 max-w-[42ch] text-callout text-dim">{body}</p>}
|
||||
{action && <div className="mt-5">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Shared shell for every text control, so focus looks the same everywhere. */
|
||||
export const controlBase =
|
||||
"w-full rounded-control border border-edge bg-[var(--surface-2)] px-3 py-2 " +
|
||||
"text-body text-ink outline-none placeholder:text-mute " +
|
||||
"transition-[border-color,box-shadow,background] duration-150 ease-out " +
|
||||
"focus:border-accent-line focus:bg-[var(--surface)] " +
|
||||
"focus:shadow-[0_0_0_3px_var(--accent-soft)] " +
|
||||
"disabled:opacity-45 disabled:cursor-not-allowed";
|
||||
|
||||
export const Input = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.InputHTMLAttributes<HTMLInputElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<input ref={ref} className={cn(controlBase, className)} {...props} />
|
||||
));
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea ref={ref} className={cn(controlBase, "resize-y leading-relaxed", className)} {...props} />
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
/**
|
||||
* Options inherit the surface token rather than a hardcoded `#12141c`, which
|
||||
* previously rendered dark dropdown items on a white page in light mode.
|
||||
*/
|
||||
export const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<select ref={ref} className={cn(controlBase, "cursor-pointer pr-8", className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
));
|
||||
Select.displayName = "Select";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: React.ReactNode;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<label htmlFor={htmlFor} className="text-callout font-medium text-dim">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-caption text-mute">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One progress bar. There were six hand-rolled ones with four different
|
||||
* heights, while @radix-ui/react-progress sat installed and unimported.
|
||||
*/
|
||||
export function Progress({
|
||||
value,
|
||||
tone = "accent",
|
||||
size = "md",
|
||||
className,
|
||||
label,
|
||||
}: {
|
||||
value: number; // 0..100
|
||||
tone?: "accent" | "ok" | "warn" | "err" | "art";
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const pct = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(pct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-pill bg-[var(--surface-3)]",
|
||||
size === "sm" ? "h-1" : "h-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-pill transition-[width] duration-500 ease-out",
|
||||
{ accent: "bg-accent", ok: "bg-ok", warn: "bg-warn", err: "bg-err", art: "bg-art" }[tone],
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMotionOK } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* A real modal, on Radix, which was already a dependency and never imported.
|
||||
* That matters beyond tidiness: it brings focus trapping, restore-on-close,
|
||||
* Escape, scroll locking and aria wiring that hand-rolled inline confirms
|
||||
* (the previous pattern) do not have.
|
||||
*
|
||||
* Presents as a bottom sheet on phones and a centred panel from `sm:` up.
|
||||
*/
|
||||
export function Sheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
|
||||
// Radix locks scrolling by injecting a <style> element, which this app's CSP
|
||||
// (style-src 'self', no 'unsafe-inline') blocks outright — the console says
|
||||
// so every time a sheet opens. Setting the property through the CSSOM is not
|
||||
// markup, so it is allowed, and it is the half that actually matters:
|
||||
// without it the page behind the sheet scrolls under your finger on a phone.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<Dialog.Portal forceMount>
|
||||
<Dialog.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: ok ? 0.18 : 0 }}
|
||||
className="fixed inset-0 z-[100] bg-[var(--scrim)] backdrop-blur-[2px]"
|
||||
/>
|
||||
</Dialog.Overlay>
|
||||
<Dialog.Content asChild forceMount>
|
||||
<motion.div
|
||||
initial={ok ? { opacity: 0, y: 24, scale: 0.98 } : false}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={ok ? { opacity: 0, y: 16, scale: 0.98 } : { opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className={cn(
|
||||
"sheet fixed z-[101] flex flex-col overflow-hidden",
|
||||
"inset-x-0 bottom-0 max-h-[86vh] rounded-t-sheet rounded-b-none",
|
||||
"sm:inset-x-auto sm:bottom-auto sm:left-1/2 sm:top-1/2",
|
||||
"sm:max-h-[82vh] sm:w-[min(560px,calc(100vw-32px))]",
|
||||
"sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-sheet",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3 border-b border-edge px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<Dialog.Title className="text-headline">{title}</Dialog.Title>
|
||||
{description && (
|
||||
<Dialog.Description className="mt-0.5 text-callout text-dim">
|
||||
{description}
|
||||
</Dialog.Description>
|
||||
)}
|
||||
</div>
|
||||
<Dialog.Close
|
||||
aria-label="Close"
|
||||
className="focus-ring -mr-1 grid h-8 w-8 shrink-0 place-items-center
|
||||
rounded-control text-dim transition-colors hover:bg-[var(--faint)] hover:text-ink"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex justify-end gap-2 border-t border-edge px-5 py-3.5">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</motion.div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shape-aware. There was one fixed 68px row used for the admin dashboard, the
|
||||
* discover grid and the review queue alike, so the loading state never
|
||||
* resembled what actually arrived.
|
||||
*/
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("shimmer rounded-card border border-edge", className)} />;
|
||||
}
|
||||
|
||||
export function SkeletonRows({ n = 6 }: { n?: number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-row">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[64px]" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCards({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<Skeleton key={i} className="h-[84px] rounded-panel" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonGrid({ n = 8 }: { n?: number }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(148px,1fr))] gap-4">
|
||||
{Array.from({ length: n }, (_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full rounded-panel" />
|
||||
<Skeleton className="h-3 w-3/4 rounded-pill border-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** CSS-driven, so the reduced-motion block in globals.css actually stops it. */
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block h-3.5 w-3.5 shrink-0 rounded-full border-2 border-current",
|
||||
"border-r-transparent [animation:spin_0.7s_linear_infinite]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search } from "lucide-react";
|
||||
import type { Job } from "@/lib/api";
|
||||
import { item, useMotionOK } from "@/lib/motion";
|
||||
|
||||
const EXAMPLES = [
|
||||
"Radiohead Creep",
|
||||
"Kendrick Lamar Money Trees",
|
||||
"Fleetwood Mac Dreams",
|
||||
"Tame Impala The Less I Know The Better",
|
||||
"Frank Ocean Ivy",
|
||||
];
|
||||
|
||||
/**
|
||||
* The first screen.
|
||||
*
|
||||
* It used to be a floating disc, a heading, and five hardcoded example chips —
|
||||
* a decorated placeholder that said nothing about this install. Leading with
|
||||
* the artwork of what has actually been imported makes the empty state a
|
||||
* library view instead, and falls back to the examples only when there is
|
||||
* genuinely nothing to show.
|
||||
*/
|
||||
export function Welcome({
|
||||
onPick,
|
||||
recent,
|
||||
}: {
|
||||
onPick: (q: string) => void;
|
||||
recent: Job[];
|
||||
}) {
|
||||
const ok = useMotionOK();
|
||||
const seen = new Set<string>();
|
||||
const imported = recent
|
||||
.filter((j) => j.state === "imported" && j.cover)
|
||||
.filter((j) => {
|
||||
const k = `${j.artist}|${j.album ?? j.title}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="mb-8 max-w-[52ch] pt-6">
|
||||
<h1 className="text-hero">Find anything.</h1>
|
||||
<p className="mt-2.5 text-body text-dim">
|
||||
Search a song, preview it, and press Get. It arrives in your library correctly
|
||||
tagged — right artist, right album, right track number.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{imported.length > 0 ? (
|
||||
<section aria-labelledby="recent-h">
|
||||
<h2 id="recent-h" className="mb-3 text-headline">
|
||||
Recently added
|
||||
</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(132px,1fr))] gap-4">
|
||||
{imported.map((j, i) => (
|
||||
<motion.button
|
||||
key={j.id}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(`${j.artist} ${j.title}`)}
|
||||
className="focus-ring group text-left"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={j.cover!}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="lift mb-2 aspect-square w-full rounded-panel border border-edge
|
||||
object-cover shadow-[var(--e1)]"
|
||||
/>
|
||||
<div className="text-callout font-medium clamp1">{j.title}</div>
|
||||
<div className="text-caption text-dim clamp1">{j.artist}</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="try-h">
|
||||
<h2 id="try-h" className="mb-3 flex items-center gap-2 text-headline">
|
||||
<Search className="h-4 w-4 text-mute" /> Try one of these
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXAMPLES.map((e, i) => (
|
||||
<motion.button
|
||||
key={e}
|
||||
{...item(i, ok)}
|
||||
onClick={() => onPick(e)}
|
||||
className="focus-ring rounded-pill border border-edge bg-[var(--surface)] px-3.5
|
||||
py-1.5 text-callout text-dim transition-colors hover:border-accent-line
|
||||
hover:bg-accent-soft hover:text-accent"
|
||||
>
|
||||
{e}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,6 +129,15 @@ export type Job = {
|
||||
duration: number | null;
|
||||
requested_by: string | null;
|
||||
created_at: string;
|
||||
/** The peer currently serving this transfer, if one is. */
|
||||
username: string | null;
|
||||
remote_file: string | null;
|
||||
confidence: number | null;
|
||||
match_why: string | null;
|
||||
/** Peers already attempted for this request, and why each one fell through. */
|
||||
tried: { username: string; filename: string; reason: string }[];
|
||||
attempts: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
export type Usage = { requests: number; storage: number };
|
||||
@@ -248,8 +257,51 @@ function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
|
||||
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
||||
|
||||
export type Health = {
|
||||
ok: boolean;
|
||||
slskd?: number | string;
|
||||
logged_in?: boolean;
|
||||
shared_files?: number | null;
|
||||
library_writable?: boolean;
|
||||
downloads_readable?: boolean;
|
||||
};
|
||||
|
||||
export 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 = {
|
||||
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"),
|
||||
health: () => get<Health>("/api/health"),
|
||||
search: (q: string) =>
|
||||
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
||||
suggest: (q: string, signal?: AbortSignal) =>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion, type Transition, type Variants } from "framer-motion";
|
||||
|
||||
/**
|
||||
* One motion vocabulary.
|
||||
*
|
||||
* There were four different stagger formulas and five entrance offsets across
|
||||
* the components, so nothing quite moved like anything else. More importantly,
|
||||
* the `prefers-reduced-motion` block in globals.css only governs CSS: Framer
|
||||
* animates via the CSSOM from JS, so every entrance, the aurora blobs and the
|
||||
* pulsing status dots all ran at full strength for people who had explicitly
|
||||
* asked them not to. `useMotionOK()` is the fix, and every animated component
|
||||
* consults it.
|
||||
*/
|
||||
export function useMotionOK() {
|
||||
return !useReducedMotion();
|
||||
}
|
||||
|
||||
/** Apple's motion reads as weight, not duration: overshoot slightly, settle. */
|
||||
export const spring: Transition = { type: "spring", stiffness: 420, damping: 34 };
|
||||
export const springSoft: Transition = { type: "spring", stiffness: 260, damping: 30 };
|
||||
|
||||
export const rise: Variants = {
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
shown: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export const fade: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
shown: { opacity: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Capped, so item 400 of a long list does not wait eight seconds for its turn.
|
||||
*/
|
||||
export function stagger(i: number, step = 0.025, max = 0.3) {
|
||||
return Math.min(i * step, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrance props for a list item, honouring the reduced-motion setting.
|
||||
*
|
||||
* Note what the reduced-motion branch does NOT do: return `{}`. Dropping the
|
||||
* props entirely removes `animate` while `initial` has already painted
|
||||
* opacity 0, and the element stays invisible forever. `useReducedMotion()`
|
||||
* also reports false on the first render and flips after mount, so that lands
|
||||
* exactly on the people who asked for less motion and shows them a blank page.
|
||||
* Instead the element is mounted already in its final state.
|
||||
*/
|
||||
export function item(i: number, ok: boolean) {
|
||||
if (!ok) return { initial: false as const, animate: "shown" as const, variants: rise };
|
||||
return {
|
||||
initial: "hidden" as const,
|
||||
animate: "shown" as const,
|
||||
variants: rise,
|
||||
transition: { ...springSoft, delay: stagger(i) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Entrance for a whole view. Deliberately translate-only, with no fade.
|
||||
*
|
||||
* This wrapper contains the entire screen, so anything that can leave it at
|
||||
* opacity 0 leaves the app looking broken rather than looking unanimated. A
|
||||
* background tab throttles rAF and freezes an in-flight animation part way —
|
||||
* it resumes on focus, but a stalled fade is a blank page and a stalled slide
|
||||
* is a few pixels of offset nobody notices. Fades stay on individual items,
|
||||
* where a stall is cosmetic.
|
||||
*/
|
||||
const slide: Variants = {
|
||||
hidden: { y: 8 },
|
||||
shown: { y: 0 },
|
||||
};
|
||||
|
||||
export function panel(ok: boolean) {
|
||||
if (!ok) return { initial: false as const, animate: "shown" as const, variants: slide };
|
||||
return {
|
||||
initial: "hidden" as const,
|
||||
animate: "shown" as const,
|
||||
variants: slide,
|
||||
transition: { duration: 0.22, ease: [0.22, 0.9, 0.28, 1] as const },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* URL state for a statically exported app.
|
||||
*
|
||||
* There was none: `tab` and the album/artist view lived in React state alone,
|
||||
* so a refresh always dumped you back at the welcome screen, the browser Back
|
||||
* button did nothing, and no view could be linked to anyone. `output: "export"`
|
||||
* rules out dynamic route segments, so the state rides in the query string and
|
||||
* is driven through the History API.
|
||||
*/
|
||||
export type Tab =
|
||||
| "search"
|
||||
| "discover"
|
||||
| "jobs"
|
||||
| "review"
|
||||
| "bulk"
|
||||
| "settings"
|
||||
| "admin";
|
||||
|
||||
export type Nav = {
|
||||
tab: Tab;
|
||||
view?: "album" | "artist";
|
||||
id?: string;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
const TABS: Tab[] = ["search", "discover", "jobs", "review", "bulk", "settings", "admin"];
|
||||
|
||||
export function parse(search: string): Nav {
|
||||
const p = new URLSearchParams(search);
|
||||
const raw = p.get("tab");
|
||||
const tab = (TABS as string[]).includes(raw || "") ? (raw as Tab) : "search";
|
||||
const v = p.get("view");
|
||||
return {
|
||||
tab,
|
||||
view: v === "album" || v === "artist" ? v : undefined,
|
||||
id: p.get("id") || undefined,
|
||||
q: p.get("q") || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function href(n: Nav): string {
|
||||
const p = new URLSearchParams();
|
||||
if (n.tab !== "search") p.set("tab", n.tab);
|
||||
if (n.view) p.set("view", n.view);
|
||||
if (n.id) p.set("id", n.id);
|
||||
if (n.q) p.set("q", n.q);
|
||||
const s = p.toString();
|
||||
return s ? `?${s}` : "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the URL and writes it back, without Next's router.
|
||||
*
|
||||
* `next/navigation`'s useSearchParams forces a Suspense boundary under static
|
||||
* export and re-renders the tree on every keystroke of a search query. The
|
||||
* History API is enough for what this needs and keeps Back working.
|
||||
*
|
||||
* useSyncExternalStore rather than useState-in-an-effect: the URL is external
|
||||
* state that React does not own, and this is the hook designed for that. It
|
||||
* also gets the static-export case right — the prerendered HTML has no URL to
|
||||
* read, so the server snapshot is the empty query, and the client subscribes
|
||||
* from its first render without a mismatch.
|
||||
*/
|
||||
const LOCATION_EVENT = "muse:navigate";
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
window.addEventListener("popstate", cb);
|
||||
window.addEventListener(LOCATION_EVENT, cb);
|
||||
return () => {
|
||||
window.removeEventListener("popstate", cb);
|
||||
window.removeEventListener(LOCATION_EVENT, cb);
|
||||
};
|
||||
}
|
||||
|
||||
const clientSearch = () => window.location.search;
|
||||
const serverSearch = () => "";
|
||||
|
||||
export function useNav(): [Nav, (n: Nav, replace?: boolean) => void] {
|
||||
const search = React.useSyncExternalStore(subscribe, clientSearch, serverSearch);
|
||||
const nav = React.useMemo(() => parse(search), [search]);
|
||||
|
||||
const go = React.useCallback((n: Nav, replace = false) => {
|
||||
const url = href(n);
|
||||
if (replace) window.history.replaceState(null, "", url);
|
||||
else window.history.pushState(null, "", url);
|
||||
// pushState does not fire popstate, so tell our own subscribers.
|
||||
window.dispatchEvent(new Event(LOCATION_EVENT));
|
||||
}, []);
|
||||
|
||||
return [nav, go];
|
||||
}
|
||||
Reference in new issue
Block a user