Compare commits

...

22 Commits

Author SHA1 Message Date
Senko-san d1b2b40ffd feat(metadata): implement single-track metadata editor page (§A7)
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Replace the placeholder with a controlled form for title/artist/album/
year/genre/track number, an AcoustID "find matches" action showing
ranked candidates with confidence, a diff/apply picker, a re-enrich
button, and save via PUT /metadata. Adds matches/apply API endpoints,
mappers, types, and en/ru i18n strings. Batch editor remains a
placeholder (deferred).
2026-06-13 14:36:17 +03:00
Senko-san 8a70f478c3 feat: track info drawer (Get Info-style)
Add a right-side track info drawer that sits to the right of the queue
panel when both are open. Shows a large cover, title/artist/album links,
a Play/Queue/Edit actions row, and Status/General/File/Identifiers
sections (empty rows omitted). Opens from the track context menu, the
player now-playing tile, and the queue now-playing card.

- ui slice: trackInfoId + open/closeTrackInfo
- TrackInfoDrawer rendered after QueuePanel in AppShell; overlays content
  on narrow viewports
- map source/createdAt/enrichedAt from the wire (were unmapped)
- formatDateTime helper, info icon, i18n (en/ru)
- drop orphaned toggleNowPlaying/isNowPlayingOpen from player slice

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:02:38 +03:00
Senko-san 9c344b98c4 fix(player): show live track metadata, not the stale queue snapshot
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The queue slice stores denormalized display fields captured at play-time, so the
player and queue panel kept showing pre-enrichment title/artist after a track's
metadata was updated — the library (RTKQ cache) and the player disagreed.

Add useResolvedQueueEntry: read through to the RTKQ Track cache and prefer its
fresh values, keeping the snapshot only as instant/offline fallback. Wire it into
PersistentPlayer (now-playing + cover) and QueuePanel (now-playing + up-next
rows), so enrichment updates reach the player through the same Track tags that
refresh the library.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:37:34 +03:00
Senko-san 42080b37ea chore: login page upd
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
2026-06-13 13:30:17 +03:00
Senko-san a37c19fd45 feat(library): surface metadata enrichment status, errors and covers
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
The mapper dropped metadata_status and hardcoded availability, so enrichment
state was invisible and a just-uploaded track never appeared to change. Map
metadata_status/metadata_error/has_cover onto Track; add MetadataStatusBadge
(pending spinner / enriched / failed-with-reason / manual) shown in TrackRow,
and serve token-bearing track covers via getTrackCoverUrl.

UploadPage now polls each uploaded track (stops once enrichment settles) so the
resolved title/artist — or a failure reason — appears live. i18n in en + ru.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 13:29:22 +03:00
Senko-san facc215450 chore: update/make more clear connect flow 2026-06-13 12:35:20 +03:00
olly 98e9344261 chore: bump modern-sk ver.
Docker Build & Publish / build (push) Successful in 34s
Docker Build & Publish / push (push) Failing after 3m3s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 20:09:27 +03:00
Senko-san 1228118027 fix(offline): include provided in RTKQ rehydration payload
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
RTK Query 2.12's invalidation slice reads `provided.tags` during cache
rehydration (`Object.entries(provided.tags ?? {})`). Our persisted
snapshot only carried `{ queries, mutations }`, so `provided` was
undefined and `.tags` threw on every startup with a cached snapshot —
crashing the app inside the rehydrate reducer / immer produce.

Snapshot now carries the real `provided` (so invalidation tags
rehydrate), and `load()` defaults it to `{ tags: {}, keys: {} }` so
snapshots persisted before this field existed recover without a manual
localStorage clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:29:42 +03:00
Senko-san 538cfb9c5b feat(auth): registration mode on ConnectPage (PUBLIC_ENABLE_REGISTRATION)
Docker Build & Publish / build (push) Failing after 1m42s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
Add a login/register toggle to ConnectPage backed by a new
useRegisterMutation (register -> /auth/me, mirroring login). The toggle
is shown only when REGISTRATION_ENABLED, resolved with the same
precedence as the API base URL: runtime window.__APP_CONFIG__ >
PUBLIC_ENABLE_REGISTRATION env > default true. The prod runtime-config
script injects the runtime flag. The backend's ALLOW_REGISTRATION stays
the real authority; this only gates the UI. EN/RU strings added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:07:07 +03:00
olly 2ad3b128d6 fix: backend url normalization
Docker Build & Publish / build (push) Failing after 3m9s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-10 13:49:38 +03:00
Senko-san 55aa8933af fix(theme): kill flash of white on dark-themed load
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
The app painted white until <ThemeProvider> mounted and set data-theme,
then snapped to the dark theme. Two fixes:

- Inline head script (rsbuild html.tags) sets data-theme before first
  paint, mirroring modern-sk's exact logic (localStorage 'modern-sk-theme'
  || 'dark') so there's no second flip when the provider mounts. Inline =
  zero round-trips.
- body now paints var(--color-bg) so the themed background shows before
  React mounts #root and layers the felt grain on top.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:18:29 +03:00
Senko-san dacb8b9278 feat(api): real login + listening wired to the backend contract
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled
Replace the faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.

- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
  expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
  audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
  page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
  append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:12:44 +03:00
Senko-san bcfb36d53e feat: make API base URL runtime-configurable
The PROD image baked PUBLIC_API_BASE_URL at build time (rsbuild inlines
PUBLIC_* vars), so a prebuilt image could only ever target a same-origin
'/api/v1' and needed a reverse proxy in front. Move the operator default to
runtime so one image can point at any backend origin without rebuilding.

- public/config.js: committed stub setting window.__APP_CONFIG__ = {}, used
  as the dev/build-time default and overwritten in prod at container start.
- rsbuild.config.ts: inject a classic (non-deferred) <script src="/config.js">
  into <head> so it runs before the deferred app bundle.
- src/config/env.ts: DEFAULT_API_BASE_URL now resolves
  window.__APP_CONFIG__.apiBaseUrl > import.meta.env.PUBLIC_API_BASE_URL >
  '/api/v1'. The user-chosen instance still wins over all of these.
- dockerfiles/30-runtime-config.sh: nginx /docker-entrypoint.d hook that
  regenerates /config.js from $PUBLIC_API_BASE_URL on every start.
- Dockerfile.prod: install the hook (build-time ARG is now just a fallback).
- nginx.conf: serve /config.js with Cache-Control: no-store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:40:59 +03:00
Senko-san 451dbb94a8 fix: cicd
Docker Build & Publish / build (push) Successful in 1m49s
Docker Build & Publish / push (push) Successful in 13s
Docker Build & Publish / Prune old image versions (push) Successful in 1s
2026-06-07 21:31:50 +03:00
Senko-san af0d8e7646 feat: build
Docker Build & Publish / build (push) Failing after 1m0s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:29:27 +03:00
Senko-san f712f871f1 feat: build
Docker Build & Publish / build (push) Failing after 11s
Docker Build & Publish / push (push) Has been skipped
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:26:57 +03:00
Senko-san a2fa425853 feat: build
Docker Build & Publish / build-and-push (push) Failing after 1m24s
Docker Build & Publish / Prune old image versions (push) Has been skipped
2026-06-07 21:11:50 +03:00
Senko-san ceee9b9d12 feat(offline): make the web UI usable without a reachable backend
Three tiers of offline support, all scoped to the active backend's
localStorage namespace (mirroring the auth slice):

Tier 1 — persist client state. queue + player slices are saved (queue
entries/index/source; player track/position/volume/repeat/shuffle) and
rehydrated on load, so a reload with no backend restores where the user
left off. Playback never auto-resumes (browsers block autoplay). Retires
the DEMO_QUEUE and isQueueOpen:true stubs.

Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists
are snapshotted (fulfilled queries only) and replayed via RTKQ's
extractRehydrationInfo at startup, so the library renders read-only when
the backend is down. ConnectionStatus tooltip flags cached data offline.
No server data is copied into a slice — the cache feeds itself back.

Tier 3 — service worker audio + cover cache (PWA). Audio streams are
cached keyed by content id (token stripped), range-aware (synthetic 206
slicing), with a 500MB LRU cap, so already-played tracks play fully
offline. Cover art uses stale-while-revalidate in its own bounded cache.
Module worker (ESM); pure helpers split into sw-core.js and unit-tested.
Web app manifest enables "Install app". Player source badge now reflects
real cached state.

tsc clean, lint clean, 19 new tests pass, production build verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:59:31 +03:00
Senko-san 61dbb1abd2 feat(upload): wire A8 local track upload to backend
Implement the A8 upload screen against the existing /upload contract:
- UploadResponse type ({track_id, title, already_exists}) + mutation typed to it
- buildUploadFormData helper (single file under field `file`, per FastAPI)
- UploadPage: drag-and-drop + file picker, client-side queue with
  concurrency cap (3), per-file status badges, retry on error,
  already_exists -> "Already in library", deep-link to A7 metadata editor
- i18n upload.* section (en/ru) incl. "metadata pending" hint

Indeterminate spinner per file; percent progress is a follow-up
(needs an XHR baseQuery — fetchBaseQuery gives no upload progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 18:47:59 +03:00
Senko-san aed0572071 Scaffold global navigation aligned to routes plan
Build out the full web route map from music-selfhost-routes.md as
scaffolding (no functionality on new screens):

- Full route tree: /login, /albums/:id, /artists/:id, /playlists(+detail),
  /discover, /upload, metadata editor (single + batch), /storage/maintenance,
  /queue, nested /settings and /admin, and a 404.
- Sidebar rebuilt to the A1 spec with permission-gated Discover/Upload.
- ProtectedRoute gains requirePermission; Permission exported.
- AppShell wraps Outlet in a Suspense boundary for lazy routes.
- Reusable Placeholder + SubNav; Settings/Admin become nested layouts.
- Settings/Profile: wired language + theme selectors.
- Remove orphaned Home feature (web has no Home; / -> /library) and the
  now-unused house icon + nav.home keys.
- i18n keys (en + ru) and CSS for page-title/sub-nav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 17:05:21 +03:00
Senko-san e45bcef3a5 feat: i18n 2026-06-06 15:23:07 +03:00
Senko-san bbd59cc225 feat: move to npm package & small fixes 2026-06-06 14:07:17 +03:00
89 changed files with 5475 additions and 1044 deletions
+4
View File
@@ -1,2 +1,6 @@
# Default backend URL (overridable at runtime in the UI)
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
# Show the public sign-up UI on the connect screen. Set to false to hide it.
# The backend's ALLOW_REGISTRATION is the real authority; this only gates the UI.
PUBLIC_ENABLE_REGISTRATION=true
+121
View File
@@ -0,0 +1,121 @@
name: Docker Build & Publish
on:
push:
branches: [master]
workflow_dispatch:
env:
# Number of tagged (non-latest) versions to keep per image name.
KEEP_VERSIONS: "5"
jobs:
build:
runs-on: ubuntu-latest
outputs:
host: ${{ steps.meta.outputs.host }}
image: ${{ steps.meta.outputs.image }}
sha: ${{ steps.meta.outputs.sha }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve registry metadata
id: meta
run: |
host=$(echo "${{ gitea.server_url }}" | sed 's|https\?://||; s|/$||')
repo_lc=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
echo "host=$host" >> "$GITHUB_OUTPUT"
echo "image=$host/$repo_lc" >> "$GITHUB_OUTPUT"
echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
file: dockerfiles/Dockerfile.prod
push: false
build-args: |
PUBLIC_API_BASE_URL=/api/v1
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }}
outputs: type=docker,dest=/tmp/image.tar
- name: Upload image artifact
uses: actions/upload-artifact@v3
with:
name: docker-image
path: /tmp/image.tar
retention-days: 1
push:
needs: build
runs-on: ubuntu-latest
steps:
- name: Download image artifact
uses: actions/download-artifact@v3
with:
name: docker-image
path: /tmp
- name: Load image
run: docker load < /tmp/image.tar
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ needs.build.outputs.host }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
- name: Push image
run: |
docker push ${{ needs.build.outputs.image }}:latest
docker push ${{ needs.build.outputs.image }}:${{ needs.build.outputs.sha }}
cleanup:
name: Prune old image versions
needs: push
runs-on: ubuntu-latest
steps:
- name: Delete versions beyond KEEP_VERSIONS
env:
GITEA_URL: ${{ gitea.server_url }}
OWNER: ${{ gitea.repository_owner }}
IMAGE: ${{ gitea.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_REGISTRY_TOKEN }}
run: |
image=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
# List all container package versions for this image (page size 50 is
# enough for typical repos; increase if you push very frequently).
response=$(curl -sf \
-H "Authorization: token $TOKEN" \
-H "Accept: application/json" \
"${GITEA_URL}/api/v1/packages/${OWNER}?type=container&limit=50&q=${image}")
# Keep the KEEP_VERSIONS newest SHA-tagged versions; always preserve 'latest'.
to_delete=$(printf '%s' "$response" \
| jq -r \
--arg name "$image" \
--argjson keep "$KEEP_VERSIONS" \
'[.[] | select(.name == $name and .version != "latest")]
| sort_by(.created) | reverse
| .[$keep:][].version')
if [ -z "$to_delete" ]; then
echo "Nothing to prune."
exit 0
fi
while IFS= read -r version; do
echo "Deleting ${image}:${version}"
curl -sf -X DELETE \
-H "Authorization: token $TOKEN" \
"${GITEA_URL}/api/v1/packages/${OWNER}/container/${image}/${version}" \
&& echo " ok" || echo " failed (may already be gone, continuing)"
done <<< "$to_delete"
+1
View File
@@ -0,0 +1 @@
@olly:registry=https://git.ollyhearn.ru/api/packages/olly/npm/
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Write the SPA's runtime operator config at container start.
#
# The nginx base image runs every /docker-entrypoint.d/*.sh before launching
# nginx, so this overwrites the build-time public/config.js stub with the
# operator's runtime config ($PUBLIC_API_BASE_URL, $PUBLIC_ENABLE_REGISTRATION).
# That lets one prebuilt image target any backend origin and toggle sign-up
# without rebuilding. Resolution + precedence live in src/config/env.ts.
set -eu
: "${PUBLIC_API_BASE_URL:=/api/v1}"
: "${PUBLIC_ENABLE_REGISTRATION:=true}"
ROOT="${NGINX_HTML_ROOT:-/usr/share/nginx/html}"
# Anything but "false"/"0" enables the sign-up UI (mirrors parseFlag in env.ts).
if [ "$PUBLIC_ENABLE_REGISTRATION" = "false" ] || [ "$PUBLIC_ENABLE_REGISTRATION" = "0" ]; then
ENABLE_REGISTRATION=false
else
ENABLE_REGISTRATION=true
fi
printf 'window.__APP_CONFIG__={"apiBaseUrl":"%s","enableRegistration":%s};\n' \
"$PUBLIC_API_BASE_URL" "$ENABLE_REGISTRATION" \
>"$ROOT/config.js"
echo "runtime-config: wrote apiBaseUrl=$PUBLIC_API_BASE_URL enableRegistration=$ENABLE_REGISTRATION to $ROOT/config.js"
+1 -6
View File
@@ -4,14 +4,9 @@
# shadows the container install. Build context = mcma-webui/.
FROM node:22-slim
# `modern-sk` is a git dependency (git+https://...) — npm needs git to fetch it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json modern-sk-*.tgz ./
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
+9 -3
View File
@@ -8,13 +8,14 @@ FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json modern-sk-*.tgz ./
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars).
# Same-origin default ('/api/v1') works behind any reverse proxy.
# Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This
# is only the *fallback* now — the real value is injected at container start by
# 30-runtime-config.sh, so the image can target any backend without a rebuild.
ARG PUBLIC_API_BASE_URL=/api/v1
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
RUN npm run build
@@ -25,5 +26,10 @@ FROM nginx:1.27-alpine AS runtime
COPY dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
# Runtime config injection: the nginx image runs /docker-entrypoint.d/*.sh
# before starting, regenerating /config.js from $PUBLIC_API_BASE_URL.
COPY dockerfiles/30-runtime-config.sh /docker-entrypoint.d/30-runtime-config.sh
RUN chmod +x /docker-entrypoint.d/30-runtime-config.sh
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+7
View File
@@ -14,6 +14,13 @@ server {
try_files $uri =404;
}
# Runtime operator config — regenerated per container start, so it must
# never be cached or a redeployed backend URL would be ignored.
location = /config.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
# SPA: every unknown path falls back to index.html (client-side router).
location / {
try_files $uri $uri/ /index.html;
+91 -53
View File
@@ -8,11 +8,13 @@
"name": "mcma-webui",
"version": "1.0.0",
"dependencies": {
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "file:./modern-sk-0.1.2.tgz",
"i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
@@ -496,7 +498,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -691,6 +692,20 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@olly/modern-sk": {
"version": "0.1.5",
"resolved": "https://git.ollyhearn.ru/api/packages/olly/npm/%40olly%2Fmodern-sk/-/0.1.5/modern-sk-0.1.5.tgz",
"integrity": "sha512-rhKp4U2IovSZkgdfg4oZqyhF0GgB8oR5TPlPXg0iYQEuEtff5zAgRXS+uY3dOPg2tStG3ysHUJaohD9YS2ADiA==",
"license": "MIT",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"radix-ui": "^1.4.3"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
@@ -2464,9 +2479,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2481,9 +2493,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2498,9 +2507,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2515,9 +2521,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2818,9 +2821,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2838,9 +2838,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2858,9 +2855,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2878,9 +2872,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3501,6 +3492,43 @@
"node": ">=20.0.0"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
@@ -3707,9 +3735,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3731,9 +3756,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3755,9 +3777,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3779,9 +3798,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -3877,20 +3893,6 @@
"node": ">=4"
}
},
"node_modules/modern-sk": {
"version": "0.1.2",
"resolved": "file:modern-sk-0.1.2.tgz",
"integrity": "sha512-tKSxbtUxT0CLkGc8DK+SABlVmKsMqqQr61uvAJ8EDcrutzm+VD230hTRVzk9hp2oSo6nXeeMig7KS8v0Lz5mWw==",
"license": "MIT",
"dependencies": {
"@phosphor-icons/react": "^2.1.10",
"radix-ui": "^1.4.3"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -4105,6 +4107,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -4371,7 +4400,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4471,6 +4500,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+3 -1
View File
@@ -13,11 +13,13 @@
"test:watch": "rstest --watch"
},
"dependencies": {
"@olly/modern-sk": "^0.1.5",
"@phosphor-icons/react": "^2.1.10",
"@reduxjs/toolkit": "^2.12.0",
"modern-sk": "file:./modern-sk-0.1.2.tgz",
"i18next": "^26.3.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"react-redux": "^9.3.0",
"react-router": "^7.16.0"
},
+8
View File
@@ -0,0 +1,8 @@
// Runtime operator configuration, read by the app before the bundle loads
// (see src/config/env.ts). In the PROD image this file is OVERWRITTEN at
// container start from $PUBLIC_API_BASE_URL (dockerfiles/30-runtime-config.sh),
// so one prebuilt image can target any backend origin without a rebuild.
//
// This committed stub is the local-dev / build-time default: it leaves the
// config empty so base-URL resolution falls back to the build-time env var.
window.__APP_CONFIG__ = {};
+19
View File
@@ -0,0 +1,19 @@
{
"name": "MCMA — Music",
"short_name": "MCMA",
"description": "Self-hosted music — control center and offline-capable player.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0b0b0b",
"theme_color": "#0b0b0b",
"icons": [
{
"src": "/favicon.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any"
}
]
}
+98
View File
@@ -0,0 +1,98 @@
/*
* Service-worker core: pure helpers with NO side effects (no `self`, no
* `caches`, no `fetch`). Split out from `sw.js` so the tricky bits — HTTP Range
* parsing, LRU eviction, cache-key normalization — can be unit-tested in Node.
*
* This is an ES module: `sw.js` imports it (the SW is registered with
* { type: 'module' }) and tests import it natively — one source, no drift.
*/
// Bump the version to invalidate every cached blob on a breaking change.
export const AUDIO_CACHE = 'mcma-audio-v1';
// Synthetic same-origin key holding the LRU index ({ key: {size, lastAccess} }).
export const INDEX_URL = '/__mcma_audio_index__';
// Soft cap on total cached audio. LRU eviction keeps us under this.
export const MAX_BYTES = 500 * 1024 * 1024; // 500 MB
// Cover art (album/artist/playlist images) gets its own cache with a simple
// count cap — covers are small and stable, so no per-byte LRU is needed.
export const COVER_CACHE = 'mcma-covers-v1';
export const MAX_COVERS = 600;
// Backend stream route: /api/v1/stream/<trackId>?token=...
const STREAM_RE = /\/stream\/([^/?#]+)/;
/** The track (content) id from a stream URL, or null if it isn't one. */
export function trackIdFromUrl(url) {
const m = STREAM_RE.exec(url);
return m ? m[1] : null;
}
/**
* Canonical cache key for a stream URL: origin + path, query dropped. The auth
* token rides in the query and rotates on refresh, so keying by content path
* (origin keeps two backends from colliding) makes the cache token-stable —
* the same track is one entry regardless of which token fetched it.
*/
export function cacheKeyFor(url) {
try {
const u = new URL(url);
return u.origin + u.pathname;
} catch {
return String(url).split('?')[0];
}
}
/**
* Parse an HTTP `Range` header against a known resource size. Returns inclusive
* { start, end } byte offsets, or null for "no range / serve the whole thing".
* Handles `bytes=a-b`, `bytes=a-` (open-ended) and `bytes=-n` (last n bytes).
*/
export function parseRangeHeader(rangeHeader, size) {
if (!rangeHeader) return null;
const m = /^bytes=(\d*)-(\d*)$/.exec(String(rangeHeader).trim());
if (!m) return null;
let start = m[1] === '' ? null : parseInt(m[1], 10);
let end = m[2] === '' ? null : parseInt(m[2], 10);
if (start === null && end === null) return null;
if (start === null) {
// suffix range: the final `end` bytes
start = Math.max(0, size - end);
end = size - 1;
} else if (end === null) {
end = size - 1;
}
if (Number.isNaN(start) || Number.isNaN(end)) return null;
if (end >= size) end = size - 1;
if (start < 0 || start > end) return null;
return { start, end };
}
/**
* Choose which cached entries to evict (least-recently-used first) so that the
* existing total plus an incoming blob fits under `maxBytes`. Returns the list
* of keys to delete; empty when there's already room.
*
* `index` is { [key]: { size, lastAccess } }.
*/
export function selectEvictions(index, incomingSize, maxBytes) {
let total = incomingSize;
for (const k in index) total += index[k].size || 0;
if (total <= maxBytes) return [];
const entries = Object.keys(index)
.map((k) => ({
key: k,
lastAccess: index[k].lastAccess || 0,
size: index[k].size || 0,
}))
.sort((a, b) => a.lastAccess - b.lastAccess); // oldest first
const evict = [];
for (const e of entries) {
if (total <= maxBytes) break;
evict.push(e.key);
total -= e.size;
}
return evict;
}
+256
View File
@@ -0,0 +1,256 @@
/*
* MCMA service worker — Tier 3 offline support: audio blob cache.
*
* It sits between the app and the network for audio-stream requests only
* (`/stream/<id>`). The first time a track is streamed it's copied
* into the Cache API (keyed by content id, token stripped); afterwards — or
* whenever the backend is unreachable — playback is served straight from the
* cache, so already-heard tracks play with no network at all.
*
* Pure helpers (range parsing, LRU, key normalization) live in sw-core.js so
* they can be unit-tested; this file owns the side-effectful cache/network I/O.
* Registered as a module worker ({ type: 'module' }), so it uses ES imports.
*/
import {
AUDIO_CACHE,
INDEX_URL,
MAX_BYTES,
COVER_CACHE,
MAX_COVERS,
trackIdFromUrl,
cacheKeyFor,
parseRangeHeader,
selectEvictions,
} from './sw-core.js';
self.addEventListener('install', () => {
// Activate immediately on first install / update — no stale SW lingering.
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
if (trackIdFromUrl(req.url)) {
event.respondWith(handleAudio(event)); // audio stream → range-aware cache
return;
}
if (req.destination === 'image') {
event.respondWith(handleImage(event)); // cover art → stale-while-revalidate
}
});
async function handleAudio(event) {
const req = event.request;
const key = cacheKeyFor(req.url);
const range = req.headers.get('range');
const cache = await caches.open(AUDIO_CACHE);
// 1) Serve from cache when we have it (works fully offline).
const cached = await cache.match(key);
if (cached) {
event.waitUntil(touch(key));
return range ? buildRangeResponse(cached, range) : cached.clone();
}
// 2) Cache miss → fetch the WHOLE file (strip Range) so we can store a
// complete copy, then satisfy the original request (range-sliced if asked).
try {
const fullReq = new Request(req.url, { headers: withoutRange(req.headers) });
const resp = await fetch(fullReq);
if (isCacheable(resp)) {
event.waitUntil(storeInCache(key, resp.clone()));
}
return range ? buildRangeResponse(resp, range) : resp;
} catch {
// Offline and never cached — nothing we can do for this one.
return new Response('Offline and not cached', {
status: 504,
statusText: 'Offline',
});
}
}
/**
* Cover art: stale-while-revalidate. Serve the cached image instantly (so the
* library renders offline), refresh it in the background, and cache fresh hits.
* Cover URLs are token-free and stable, and `<img>` is happy with opaque
* cross-origin responses — so unlike audio, these cache even cross-origin.
*/
async function handleImage(event) {
const req = event.request;
const cache = await caches.open(COVER_CACHE);
const cached = await cache.match(req);
const fromNetwork = fetch(req)
.then((resp) => {
if (resp && (resp.status === 200 || resp.type === 'opaque')) {
return cache
.put(req, resp.clone())
.then(() => trimCovers(cache))
.then(() => resp);
}
return resp;
})
.catch(() => null);
if (cached) {
event.waitUntil(fromNetwork); // revalidate without blocking the response
return cached;
}
const resp = await fromNetwork;
return resp || new Response('', { status: 504, statusText: 'Offline' });
}
/** Keep the cover cache bounded — drop the oldest entries past the cap. */
async function trimCovers(cache) {
const keys = await cache.keys();
const excess = keys.length - MAX_COVERS;
for (let i = 0; i < excess; i++) await cache.delete(keys[i]);
}
/** Only cache readable, complete 200 responses — opaque/partial are useless. */
function isCacheable(resp) {
return (
resp.status === 200 &&
resp.type !== 'opaque' &&
resp.type !== 'opaqueredirect'
);
}
function withoutRange(headers) {
const out = new Headers();
for (const [k, v] of headers.entries()) {
if (k.toLowerCase() !== 'range') out.set(k, v);
}
return out;
}
/** Build a 206 Partial Content response by slicing a full cached/fetched body. */
async function buildRangeResponse(response, rangeHeader) {
const buf = await response.clone().arrayBuffer();
const size = buf.byteLength;
const r = parseRangeHeader(rangeHeader, size);
const type = response.headers.get('content-type') || 'application/octet-stream';
if (!r) {
return new Response(buf, {
status: 200,
headers: {
'content-type': type,
'content-length': String(size),
'accept-ranges': 'bytes',
},
});
}
const sliced = buf.slice(r.start, r.end + 1);
return new Response(sliced, {
status: 206,
statusText: 'Partial Content',
headers: {
'content-type': type,
'content-range': `bytes ${r.start}-${r.end}/${size}`,
'content-length': String(sliced.byteLength),
'accept-ranges': 'bytes',
},
});
}
async function responseSize(resp) {
const len = resp.headers.get('content-length');
if (len) return Number(len);
const blob = await resp.blob();
return blob.size;
}
// --- LRU index -------------------------------------------------------------
// The index lives as a JSON entry in the same cache. All mutations go through a
// single serialized chain so concurrent fetch handlers can't clobber it.
let indexChain = Promise.resolve();
async function readIndex(cache) {
try {
const res = await cache.match(INDEX_URL);
if (!res) return {};
return (await res.json()) || {};
} catch {
return {};
}
}
function writeIndex(mutator) {
indexChain = indexChain
.then(async () => {
const cache = await caches.open(AUDIO_CACHE);
const index = await readIndex(cache);
await mutator(cache, index);
await cache.put(
INDEX_URL,
new Response(JSON.stringify(index), {
headers: { 'content-type': 'application/json' },
}),
);
})
.catch(() => {
/* keep the chain alive even if one write fails */
});
return indexChain;
}
async function storeInCache(key, resp) {
const size = await responseSize(resp.clone());
await writeIndex(async (cache, index) => {
for (const ek of selectEvictions(index, size, MAX_BYTES)) {
await cache.delete(ek);
delete index[ek];
}
await cache.put(key, resp);
index[key] = { size, lastAccess: Date.now() };
});
}
function touch(key) {
return writeIndex((_cache, index) => {
if (index[key]) index[key].lastAccess = Date.now();
});
}
// --- client messaging ------------------------------------------------------
// The app talks to the SW over a MessageChannel: it sends a port, we reply on
// it. Used for the offline-cache stats/controls in the UI.
self.addEventListener('message', (event) => {
const data = event.data || {};
const reply = (result) => {
if (event.ports && event.ports[0]) event.ports[0].postMessage(result);
};
event.waitUntil(
(async () => {
try {
const cache = await caches.open(AUDIO_CACHE);
const index = await readIndex(cache);
if (data.type === 'STATS') {
const keys = Object.keys(index);
const bytes = keys.reduce((s, k) => s + (index[k].size || 0), 0);
reply({ count: keys.length, bytes, maxBytes: MAX_BYTES });
} else if (data.type === 'HAS') {
reply({ cached: !!index[cacheKeyFor(data.url)] });
} else if (data.type === 'CLEAR') {
await Promise.all([
caches.delete(AUDIO_CACHE),
caches.delete(COVER_CACHE),
]);
reply({ ok: true });
} else {
reply({ error: 'unknown-message' });
}
} catch (e) {
reply({ error: String(e) });
}
})(),
);
});
+33
View File
@@ -32,5 +32,38 @@ export default defineConfig({
// no manual source.define needed. See src/config/env.ts.
html: {
title: 'MCMA',
// PWA: link the manifest + declare theme/icon so the browser offers
// "Install app". The service worker (audio offline cache) is registered
// from src/index.tsx, not here.
tags: [
// Theme bootstrap — runs inline before first paint to kill the flash of
// white on a dark-themed load. Mirrors modern-sk's own logic exactly
// (localStorage 'modern-sk-theme' || 'dark' → data-theme on <html>), so
// there's no second flip when <ThemeProvider> mounts. Inline (not an
// external file) so it costs zero round-trips.
{
tag: 'script',
children:
"(function(){try{var t=localStorage.getItem('modern-sk-theme')||'dark';document.documentElement.setAttribute('data-theme',t);}catch(e){}})();",
head: true,
append: false,
},
// Runtime operator config. A classic (non-deferred) head script, so it
// runs before the deferred app bundle and window.__APP_CONFIG__ is set by
// the time src/config/env.ts reads it. Served from public/ in dev and
// overwritten from $PUBLIC_API_BASE_URL at container start in prod.
{
tag: 'script',
attrs: { src: '/config.js' },
head: true,
append: false,
},
{
tag: 'link',
attrs: { rel: 'manifest', href: '/manifest.webmanifest' },
},
{ tag: 'meta', attrs: { name: 'theme-color', content: '#0b0b0b' } },
{ tag: 'link', attrs: { rel: 'apple-touch-icon', href: '/favicon.png' } },
],
},
});
+9 -10
View File
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
{
url: '/auth/refresh',
method: 'POST',
body: { refreshToken },
body: { refresh_token: refreshToken },
},
api,
extraOptions,
);
if (refreshResult.data) {
const {
accessToken,
refreshToken: newRefresh,
expiresIn,
} = refreshResult.data as {
accessToken: string;
refreshToken: string;
expiresIn: number;
// Backend wire format is snake_case with no TTL (see auth.ts adapter).
const { access_token, refresh_token } = refreshResult.data as {
access_token: string;
refresh_token: string;
};
api.dispatch(
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
setTokens({
accessToken: access_token,
refreshToken: refresh_token,
}),
);
result = await rawBaseQuery()(args, api, extraOptions);
} else {
+21 -10
View File
@@ -1,33 +1,44 @@
import { api } from '../index';
import { toUser, type RawUser } from '../mappers';
import type { User } from '../types';
/**
* Admin user management. The backend models authorization as `is_superuser` /
* `is_active` (no `role`/`email`); `toUser` maps superuser→role for the UI and
* the mutations translate role back to `is_superuser` on the way out.
*/
export const adminApi = api.injectEndpoints({
endpoints: (build) => ({
getUsers: build.query<User[], void>({
query: () => '/admin/users',
transformResponse: (raw: RawUser[]) => raw.map(toUser),
providesTags: ['User'],
}),
createUser: build.mutation<
User,
{
username: string;
password: string;
email?: string;
role: 'admin' | 'user';
}
{ username: string; password: string; role: 'admin' | 'user' }
>({
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
query: ({ username, password, role }) => ({
url: '/admin/users',
method: 'POST',
body: { username, password, is_superuser: role === 'admin' },
}),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'],
}),
updateUser: build.mutation<
User,
{ id: string; role?: 'admin' | 'user'; email?: string }
{ id: string; role?: 'admin' | 'user'; isActive?: boolean }
>({
query: ({ id, ...body }) => ({
query: ({ id, role, isActive }) => ({
url: `/admin/users/${id}`,
method: 'PATCH',
body,
body: {
is_superuser: role === undefined ? undefined : role === 'admin',
is_active: isActive,
},
}),
transformResponse: (raw: RawUser) => toUser(raw),
invalidatesTags: ['User'],
}),
deleteUser: build.mutation<void, string>({
+95 -11
View File
@@ -1,26 +1,110 @@
import { api } from '../index';
import type { LoginRequest, LoginResponse } from '../types';
import { toUser, type RawUser } from '../mappers';
import type {
AuthTokens,
LoginRequest,
LoginResponse,
RegisterRequest,
User,
} from '../types';
/**
* Auth seam over the backend's wire format: tokens-only login + a separate
* `/auth/me` for the user. Token mapping lives here; user mapping is shared with
* the admin endpoints via `toUser` in `mappers.ts`.
*/
/** `/auth/login` & `/auth/refresh` response shape. */
interface RawTokenResponse {
access_token: string;
refresh_token: string;
token_type?: string;
}
const toTokens = (raw: RawTokenResponse): AuthTokens => ({
accessToken: raw.access_token,
refreshToken: raw.refresh_token,
// No TTL on the wire — expiry is 401-driven (see baseQuery reauth).
});
export const authApi = api.injectEndpoints({
endpoints: (build) => ({
// Login is a two-call flow: POST /auth/login yields tokens, then GET
// /auth/me resolves the user. A queryFn chains both so callers get the
// unified { user, tokens } the UI expects in one await.
login: build.mutation<LoginResponse, LoginRequest>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
async queryFn(body, _api, _extra, baseQuery) {
const tokenRes = await baseQuery({
url: '/auth/login',
method: 'POST',
body,
});
if (tokenRes.error) return { error: tokenRes.error };
const tokens = toTokens(tokenRes.data as RawTokenResponse);
// The access token isn't in the store yet, so attach it explicitly —
// baseQuery's prepareHeaders only injects what's already in auth state.
const meRes = await baseQuery({
url: '/auth/me',
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (meRes.error) return { error: meRes.error };
const user = toUser(meRes.data as RawUser);
return { data: { user, tokens } };
},
}),
logout: build.mutation<void, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
// Sign-up mirrors login: POST /auth/register returns a token pair (the
// backend logs the new account straight in), then GET /auth/me resolves the
// user — so the UI gets the same unified { user, tokens } as login.
register: build.mutation<LoginResponse, RegisterRequest>({
async queryFn(body, _api, _extra, baseQuery) {
const tokenRes = await baseQuery({
url: '/auth/register',
method: 'POST',
body,
});
if (tokenRes.error) return { error: tokenRes.error };
const tokens = toTokens(tokenRes.data as RawTokenResponse);
const meRes = await baseQuery({
url: '/auth/me',
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
if (meRes.error) return { error: meRes.error };
const user = toUser(meRes.data as RawUser);
return { data: { user, tokens } };
},
}),
refreshToken: build.mutation<
{ accessToken: string; refreshToken: string; expiresIn: number },
{ refreshToken: string }
>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
logout: build.mutation<void, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/logout',
method: 'POST',
body: { refresh_token: refreshToken },
}),
me: build.query<import('../types').User, void>({
}),
refreshToken: build.mutation<AuthTokens, { refreshToken: string }>({
query: ({ refreshToken }) => ({
url: '/auth/refresh',
method: 'POST',
body: { refresh_token: refreshToken },
}),
transformResponse: (raw: RawTokenResponse) => toTokens(raw),
}),
me: build.query<User, void>({
query: () => '/auth/me',
transformResponse: (raw: RawUser) => toUser(raw),
providesTags: ['User'],
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
export const {
useLoginMutation,
useRegisterMutation,
useLogoutMutation,
useRefreshTokenMutation,
useMeQuery,
} = authApi;
+6
View File
@@ -1,6 +1,12 @@
import { api } from '../index';
import type { DownloadJob } from '../types';
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
// return no body / no schema). The request shapes below are provisional and the
// responses will need the same snake→camel mapper treatment as library/playlists
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
// wire these into the UI until then.
export const downloadsApi = api.injectEndpoints({
endpoints: (build) => ({
getDownloads: build.query<
+127 -9
View File
@@ -1,16 +1,61 @@
import { api } from '../index';
import {
toAlbum,
toArtist,
toMetadataMatch,
toPage,
toTrack,
type RawAlbum,
type RawArtist,
type RawMetadataMatch,
type RawPaged,
type RawTrack,
} from '../mappers';
import type {
Track,
Album,
Artist,
MetadataEdit,
MetadataMatch,
PaginatedResponse,
LibraryFilters,
} from '../types';
// The backend sorts on a small allow-list; map the UI's sort keys onto it
// (album/year aren't sortable server-side yet → fall back to recency).
const SORT_BY: Record<NonNullable<LibraryFilters['sortBy']>, string> = {
title: 'title',
artist: 'artist',
album: 'created_at',
year: 'created_at',
dateAdded: 'created_at',
};
/** UI page/pageSize → backend limit/offset. */
function paging(page?: number, pageSize?: number) {
const size = pageSize ?? 50;
return { limit: size, offset: ((page ?? 1) - 1) * size };
}
function trackParams(f: LibraryFilters) {
return {
q: f.search,
artist_id: f.artistId,
album_id: f.albumId,
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
order: f.sortOrder,
...paging(f.page, f.pageSize),
};
}
export const libraryApi = api.injectEndpoints({
endpoints: (build) => ({
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
query: (filters) => ({
url: '/tracks',
params: trackParams(filters ?? {}),
}),
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
providesTags: (result) =>
result
? [
@@ -20,7 +65,8 @@ export const libraryApi = api.injectEndpoints({
: ['Track'],
}),
getTrack: build.query<Track, string>({
query: (id) => `/library/tracks/${id}`,
query: (id) => `/tracks/${id}`,
transformResponse: (raw: RawTrack) => toTrack(raw),
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
}),
getAlbums: build.query<
@@ -32,7 +78,15 @@ export const libraryApi = api.injectEndpoints({
pageSize?: number;
} | void
>({
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
query: (p) => ({
url: '/albums',
params: {
q: p?.search,
artist_id: p?.artistId,
...paging(p?.page, p?.pageSize),
},
}),
transformResponse: (raw: RawPaged<RawAlbum>) => toPage(raw, toAlbum),
providesTags: (result) =>
result
? [
@@ -42,11 +96,13 @@ export const libraryApi = api.injectEndpoints({
: ['Album'],
}),
getAlbum: build.query<Album, string>({
query: (id) => `/library/albums/${id}`,
query: (id) => `/albums/${id}`,
transformResponse: (raw: RawAlbum) => toAlbum(raw),
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
}),
getAlbumTracks: build.query<Track[], string>({
query: (albumId) => `/library/albums/${albumId}/tracks`,
query: (albumId) => `/albums/${albumId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, albumId) => [
{ type: 'Album', id: albumId },
'Track',
@@ -56,7 +112,11 @@ export const libraryApi = api.injectEndpoints({
PaginatedResponse<Artist>,
{ search?: string; page?: number; pageSize?: number } | void
>({
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
query: (p) => ({
url: '/artists',
params: { q: p?.search, ...paging(p?.page, p?.pageSize) },
}),
transformResponse: (raw: RawPaged<RawArtist>) => toPage(raw, toArtist),
providesTags: (result) =>
result
? [
@@ -69,23 +129,77 @@ export const libraryApi = api.injectEndpoints({
: ['Artist'],
}),
getArtist: build.query<Artist, string>({
query: (id) => `/library/artists/${id}`,
query: (id) => `/artists/${id}`,
transformResponse: (raw: RawArtist) => toArtist(raw),
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
}),
getArtistAlbums: build.query<Album[], string>({
query: (artistId) => `/library/artists/${artistId}/albums`,
query: (artistId) => `/artists/${artistId}/albums`,
transformResponse: (raw: RawPaged<RawAlbum>) => raw.items.map(toAlbum),
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Album',
],
}),
getArtistTracks: build.query<Track[], string>({
query: (artistId) => `/artists/${artistId}/tracks`,
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
providesTags: (_r, _e, artistId) => [
{ type: 'Artist', id: artistId },
'Track',
],
}),
searchLibrary: build.query<
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
string
>({
query: (q) => ({ url: '/library/search', params: { q } }),
query: (q) => ({ url: '/search/library', params: { q } }),
transformResponse: (raw: {
tracks: RawTrack[];
albums: RawAlbum[];
artists: RawArtist[];
}) => ({
tracks: raw.tracks.map(toTrack),
albums: raw.albums.map(toAlbum),
artists: raw.artists.map(toArtist),
}),
providesTags: ['Track', 'Album', 'Artist'],
}),
getMetadataMatches: build.query<MetadataMatch[], string>({
query: (trackId) => `/tracks/${trackId}/metadata/matches`,
transformResponse: (raw: { items: RawMetadataMatch[] }) =>
raw.items.map(toMetadataMatch),
}),
applyMetadata: build.mutation<
Track,
{ trackId: string; edit: MetadataEdit }
>({
query: ({ trackId, edit }) => ({
url: `/tracks/${trackId}/metadata`,
method: 'PUT',
body: {
title: edit.title,
artist_name: edit.artistName,
album_title: edit.albumTitle,
year: edit.year,
genre: edit.genre,
track_number: edit.trackNumber,
},
}),
transformResponse: (raw: RawTrack) => toTrack(raw),
invalidatesTags: (_r, _e, { trackId }) => [
{ type: 'Track', id: trackId },
'Album',
'Artist',
],
}),
enrichTrack: build.mutation<{ track_id: string; job_id: string }, string>({
query: (trackId) => ({
url: `/tracks/${trackId}/metadata/enrich`,
method: 'POST',
}),
invalidatesTags: (_r, _e, trackId) => [{ type: 'Track', id: trackId }],
}),
}),
overrideExisting: false,
});
@@ -99,5 +213,9 @@ export const {
useGetArtistsQuery,
useGetArtistQuery,
useGetArtistAlbumsQuery,
useGetArtistTracksQuery,
useSearchLibraryQuery,
useLazyGetMetadataMatchesQuery,
useApplyMetadataMutation,
useEnrichTrackMutation,
} = libraryApi;
+57 -9
View File
@@ -1,20 +1,68 @@
import { api } from '../index';
/**
* Likes are an append-only event-log on the backend: state changes by POSTing a
* new `{track_id, value}` event, never by PUT/DELETE on a boolean. "Unlike" is
* just a `neutral` event superseding the prior `like`.
*/
type LikeValue = 'like' | 'dislike' | 'neutral';
interface RawLikeState {
track_id: string;
value: LikeValue;
updated_at: string;
}
export interface LikeState {
trackId: string;
value: LikeValue;
updatedAt: string;
}
const toLikeState = (r: RawLikeState): LikeState => ({
trackId: r.track_id,
value: r.value,
updatedAt: r.updated_at,
});
export const likesApi = api.injectEndpoints({
endpoints: (build) => ({
likeTrack: build.mutation<void, string>({
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
query: ({ trackId, value }) => ({
url: '/likes',
method: 'POST',
body: { track_id: trackId, value },
}),
unlikeTrack: build.mutation<void, string>({
query: (trackId) => ({
url: `/likes/tracks/${trackId}`,
method: 'DELETE',
transformResponse: (raw: RawLikeState) => toLikeState(raw),
invalidatesTags: (_r, _e, { trackId }) => [
'Like',
{ type: 'Track', id: trackId },
],
}),
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
// Latest like state for a set of tracks (drives like buttons in lists).
getLikesState: build.query<LikeState[], string[]>({
query: (trackIds) => ({
url: '/likes/state',
params: { track_ids: trackIds.join(',') },
}),
transformResponse: (raw: RawLikeState[]) => raw.map(toLikeState),
providesTags: ['Like'],
}),
}),
overrideExisting: false,
});
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi;
const { useSetLikeMutation, useGetLikesStateQuery } = likesApi;
/** Convenience hook preserving the like/unlike call sites. */
export function useLikeActions() {
const [setLike, state] = useSetLikeMutation();
return {
like: (trackId: string) => setLike({ trackId, value: 'like' }),
unlike: (trackId: string) => setLike({ trackId, value: 'neutral' }),
dislike: (trackId: string) => setLike({ trackId, value: 'dislike' }),
state,
};
}
export { useSetLikeMutation, useGetLikesStateQuery };
+36 -11
View File
@@ -1,36 +1,61 @@
import { api } from '../index';
import {
toPage,
toPlaylist,
toTrack,
type RawPaged,
type RawPlaylist,
type RawTrack,
} from '../mappers';
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
export const playlistsApi = api.injectEndpoints({
endpoints: (build) => ({
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
query: () => '/playlists',
transformResponse: (raw: RawPaged<RawPlaylist>) =>
toPage(raw, toPlaylist),
providesTags: ['Playlist'],
}),
getPlaylist: build.query<Playlist, string>({
query: (id) => `/playlists/${id}`,
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
}),
getPlaylistTracks: build.query<PlaylistTrack[], string>({
query: (id) => `/playlists/${id}/tracks`,
// The backend returns plain tracks in playlist order; position/addedAt
// aren't on TrackOut, so derive position from order and default addedAt.
transformResponse: (raw: RawPaged<RawTrack>) =>
raw.items.map((r, i) => ({
...toTrack(r),
position: i,
addedAt: r.created_at,
})),
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
}),
createPlaylist: build.mutation<
Playlist,
{ name: string; description?: string; isPublic?: boolean }
{ name: string; description?: string }
>({
query: (body) => ({ url: '/playlists', method: 'POST', body }),
query: ({ name, description }) => ({
url: '/playlists',
method: 'POST',
body: { name, description },
}),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: ['Playlist'],
}),
updatePlaylist: build.mutation<
Playlist,
{ id: string; name?: string; description?: string; isPublic?: boolean }
{ id: string; name?: string; description?: string }
>({
query: ({ id, ...body }) => ({
query: ({ id, name, description }) => ({
url: `/playlists/${id}`,
method: 'PATCH',
body,
body: { name, description },
}),
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
}),
deletePlaylist: build.mutation<void, string>({
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
}),
addTrackToPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string }
{ playlistId: string; trackId: string; position?: number }
>({
query: ({ playlistId, trackId }) => ({
query: ({ playlistId, trackId, position }) => ({
url: `/playlists/${playlistId}/tracks`,
method: 'POST',
body: { trackId },
body: { track_id: trackId, position },
}),
invalidatesTags: (_r, _e, { playlistId }) => [
{ type: 'Playlist', id: playlistId },
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
}),
removeTrackFromPlaylist: build.mutation<
void,
{ playlistId: string; trackId: string; position: number }
{ playlistId: string; trackId: string }
>({
query: ({ playlistId, position }) => ({
url: `/playlists/${playlistId}/tracks/${position}`,
query: ({ playlistId, trackId }) => ({
url: `/playlists/${playlistId}/tracks/${trackId}`,
method: 'DELETE',
}),
invalidatesTags: (_r, _e, { playlistId }) => [
+7
View File
@@ -1,6 +1,13 @@
import { api } from '../index';
import type { StorageStats } from '../types';
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
// no schema), and the real paths differ from these placeholders (`GET /storage`,
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
// `mappers.ts`) once the backend defines the storage response shapes; until then
// these are provisional and unused by the UI.
export const storageApi = api.injectEndpoints({
endpoints: (build) => ({
getStorageStats: build.query<StorageStats, void>({
+21 -1
View File
@@ -1,8 +1,13 @@
import { getApiBaseUrl } from '../../config/runtime-config';
/**
* Audio stream URL for the `<audio>` element. The access token rides as a query
* param because `<audio>` can't send an `Authorization` header; the backend
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
*/
export function getStreamUrl(trackId: string, token: string): string {
const base = getApiBaseUrl();
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`;
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
}
export function getCoverUrl(artUrl: string | undefined): string | undefined {
@@ -12,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
const base = getApiBaseUrl();
return `${base}${artUrl}`;
}
/**
* Cover image URL for a track, served by `GET /tracks/{id}/cover`. Like the
* audio stream, an `<img>` can't send an `Authorization` header, so the access
* token rides as `?token=`. Returns undefined when the track has no cover.
*/
export function getTrackCoverUrl(
trackId: string,
token: string,
hasCover: boolean,
): string | undefined {
if (!hasCover) return undefined;
const base = getApiBaseUrl();
return `${base}/tracks/${trackId}/cover?token=${encodeURIComponent(token)}`;
}
+12 -2
View File
@@ -1,9 +1,19 @@
import { api } from '../index';
import type { Track } from '../types';
import type { UploadResponse } from '../types';
/**
* Build the multipart body for `/upload`. The backend expects exactly one file
* per request under the field name `file` (anything else → FastAPI 422).
*/
export function buildUploadFormData(file: File): FormData {
const fd = new FormData();
fd.append('file', file);
return fd;
}
export const uploadApi = api.injectEndpoints({
endpoints: (build) => ({
uploadTrack: build.mutation<Track, FormData>({
uploadTrack: build.mutation<UploadResponse, FormData>({
query: (formData) => ({
url: '/upload',
method: 'POST',
+12
View File
@@ -1,5 +1,6 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './baseQuery';
import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
export const api = createApi({
reducerPath: 'api',
@@ -14,5 +15,16 @@ export const api = createApi({
'User',
'Storage',
],
// Tier 2 offline: seed the cache from the persisted snapshot dispatched at
// startup (see `store/rtkqPersist.ts`). Returning the saved queries/mutations
// lets the last-seen library render before — or instead of — any network call.
extractRehydrationInfo(action) {
if (action.type === REHYDRATE_API) {
// The api reducer reads `queries`/`mutations` off this and restores any
// fulfilled entries; pending/rejected ones are ignored automatically.
return action.payload as RehydrateApiPayload as never;
}
return undefined;
},
endpoints: () => ({}),
});
+219
View File
@@ -0,0 +1,219 @@
/**
* Backend-contract adapters: the single place where the backend's wire format
* (snake_case, lean `*Out` schemas, `{items,total,limit,offset}` paging) is
* translated into the UI's internal camelCase domain types from `types.ts`.
*
* The endpoint files (`endpoints/*.ts`) own *paths and params*; this module owns
* *shape*. Swapping or mocking a backend means rewriting the mappers here —
* nothing in components, slices, or `types.ts` changes.
*
* Where the backend's lean schema omits a field the UI type carries (cover art,
* liked state, durations on albums…), the mapper fills a safe client default and
* says why inline. Those defaults are the contract's current edges, not bugs.
*/
import type {
Album,
Artist,
MetadataMatch,
MetadataStatus,
PaginatedResponse,
Playlist,
Track,
User,
} from './types';
const METADATA_STATUSES: readonly MetadataStatus[] = [
'pending',
'enriched',
'failed',
'manual',
];
/** Map the backend's free-form status string onto the UI union, defaulting any
* unknown value to `pending` (a safe "not yet identified" state). */
const toMetadataStatus = (raw: string): MetadataStatus =>
(METADATA_STATUSES as readonly string[]).includes(raw)
? (raw as MetadataStatus)
: 'pending';
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
export interface RawPaged<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RawUser {
id: string;
username: string;
is_superuser: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface RawTrack {
id: string;
title: string;
artist_id: string;
artist_name: string;
album_id: string | null;
album_title: string | null;
duration_seconds: number | null;
file_format: string;
file_size: number;
genre: string | null;
year: number | null;
track_number: number | null;
metadata_status: string;
metadata_error: string | null;
enriched_at: string | null;
has_cover: boolean;
source: string;
created_at: string;
}
/** One AcoustID candidate, as returned by `GET /tracks/{id}/metadata/matches`. */
export interface RawMetadataMatch {
acoustid: string;
score: number;
recording_mbid: string | null;
release_group_mbid: string | null;
title: string | null;
artist: string | null;
album: string | null;
year: number | null;
}
export interface RawAlbum {
id: string;
title: string;
artist_id: string;
artist_name: string;
year: number | null;
track_count: number;
created_at: string;
}
export interface RawArtist {
id: string;
name: string;
album_count: number;
track_count: number;
created_at: string;
}
export interface RawPlaylist {
id: string;
name: string;
description: string | null;
owner_id: string;
version: number;
track_count: number;
created_at: string;
}
// ---- mappers ----
export const toUser = (r: RawUser): User => ({
id: r.id,
username: r.username,
// MVP role model: superuser → admin, everyone else → user.
role: r.is_superuser ? 'admin' : 'user',
createdAt: r.created_at,
});
export const toTrack = (r: RawTrack): Track => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
albumId: r.album_id ?? '',
albumTitle: r.album_title ?? '',
// `has_cover` says a cover exists; the actual URL (which needs a `?token=`) is
// built in the component from the track id — see `getTrackCoverUrl`. Keep
// `albumArtUrl` undefined so callers fall back to generated tile art.
albumArtUrl: undefined,
hasCover: r.has_cover,
durationMs: (r.duration_seconds ?? 0) * 1000,
// The lean TrackOut carries no availability/like state: a track returned by
// the library is on the server, and per-track like state comes from /likes.
availability: 'server',
metadataStatus: toMetadataStatus(r.metadata_status),
metadataError: r.metadata_error ?? undefined,
genre: r.genre ?? undefined,
year: r.year ?? undefined,
trackNumber: r.track_number ?? undefined,
liked: false,
format: r.file_format,
fileSize: r.file_size,
source: r.source,
createdAt: r.created_at,
enrichedAt: r.enriched_at ?? undefined,
});
export const toMetadataMatch = (r: RawMetadataMatch): MetadataMatch => ({
acoustid: r.acoustid,
score: r.score,
recordingMbid: r.recording_mbid ?? undefined,
releaseGroupMbid: r.release_group_mbid ?? undefined,
title: r.title ?? undefined,
artist: r.artist ?? undefined,
album: r.album ?? undefined,
year: r.year ?? undefined,
});
export const toAlbum = (r: RawAlbum): Album => ({
id: r.id,
title: r.title,
artistId: r.artist_id,
artistName: r.artist_name,
artUrl: undefined,
year: r.year ?? undefined,
trackCount: r.track_count,
// AlbumOut has no aggregate duration; computed client-side from tracks when
// an album is opened.
totalDurationMs: 0,
});
export const toArtist = (r: RawArtist): Artist => ({
id: r.id,
name: r.name,
artUrl: undefined,
albumCount: r.album_count,
trackCount: r.track_count,
});
export const toPlaylist = (r: RawPlaylist): Playlist => ({
id: r.id,
name: r.name,
description: r.description ?? undefined,
ownerId: r.owner_id,
trackCount: r.track_count,
totalDurationMs: 0,
// No visibility concept on the backend yet — default private.
isPublic: false,
createdAt: r.created_at,
// PlaylistOut omits updated_at; mirror created_at until it's added.
updatedAt: r.created_at,
});
/**
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
* `{items,total,page,pageSize,hasMore}`, mapping each element.
*/
export const toPage = <R, T>(
raw: RawPaged<R>,
map: (r: R) => T,
): PaginatedResponse<T> => {
const pageSize = raw.limit || raw.items.length || 1;
return {
items: raw.items.map(map),
total: raw.total,
page: Math.floor(raw.offset / pageSize) + 1,
pageSize,
hasMore: raw.offset + raw.items.length < raw.total,
};
};
+25
View File
@@ -0,0 +1,25 @@
import { createAction } from '@reduxjs/toolkit';
/*
* Tier 2 offline support contract. RTK Query can seed its cache from a
* persisted snapshot via `extractRehydrationInfo` (see `api/index.ts`). We use
* a single action whose payload is the previously-saved api slice state; the
* api reducer pulls `queries`/`mutations` out of it on startup so last-seen
* library data renders read-only while the backend is unreachable.
*
* The type string lives here (not in the store) so `api/index.ts` can match it
* without importing from the store layer (which would create a cycle).
*/
export const REHYDRATE_API = 'api/rehydrate';
export interface RehydrateApiPayload {
queries: Record<string, unknown>;
mutations: Record<string, unknown>;
// RTKQ's invalidation slice reads `provided.tags`/`provided.keys` during
// rehydration (it does `Object.entries(provided.tags ?? {})`), so `provided`
// must be an object — a bare `{ queries, mutations }` makes it crash on
// `provided.tags` of undefined. Always present; empty objects are valid.
provided: { tags: Record<string, unknown>; keys: Record<string, unknown> };
}
export const rehydrateApi = createAction<RehydrateApiPayload>(REHYDRATE_API);
+55 -1
View File
@@ -1,5 +1,13 @@
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
/**
* Metadata-enrichment state, distinct from file `availability`. `pending` = the
* worker hasn't finished (or hasn't started); `enriched` = identity found;
* `failed` = no match / a worker error (see `metadataError`); `manual` = user-
* edited and never auto-overwritten.
*/
export type MetadataStatus = 'pending' | 'enriched' | 'failed' | 'manual';
export interface Track {
id: string;
title: string;
@@ -8,16 +16,26 @@ export interface Track {
albumId: string;
albumTitle: string;
albumArtUrl?: string;
hasCover: boolean;
durationMs: number;
trackNumber?: number;
discNumber?: number;
year?: number;
genre?: string;
availability: TrackAvailability;
metadataStatus: MetadataStatus;
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
metadataError?: string;
fileSize?: number;
format?: string;
bitrate?: number;
liked: boolean;
/** Where the track entered the library (e.g. `upload`, `local_folder`). */
source?: string;
/** ISO timestamp the track was added to the library. */
createdAt?: string;
/** ISO timestamp the last successful enrichment ran; undefined if never. */
enrichedAt?: string;
}
export interface Album {
@@ -72,6 +90,12 @@ export interface DownloadJob {
updatedAt: string;
}
export interface UploadResponse {
track_id: string;
title: string;
already_exists: boolean;
}
export interface StorageStats {
totalBytes: number;
usedBytes: number;
@@ -92,7 +116,9 @@ export interface User {
export interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
// Optional: the backend's TokenResponse carries no TTL — expiry is driven by
// 401→refresh, not a client-side clock. Present only if a backend supplies it.
expiresIn?: number;
}
export interface LoginRequest {
@@ -105,6 +131,11 @@ export interface LoginResponse {
tokens: AuthTokens;
}
export interface RegisterRequest {
username: string;
password: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
@@ -130,3 +161,26 @@ export interface ApiError {
message: string;
code?: string;
}
/** One AcoustID candidate from `GET /tracks/{id}/metadata/matches` (§A7). */
export interface MetadataMatch {
acoustid: string;
/** Confidence 0..1. */
score: number;
recordingMbid?: string;
releaseGroupMbid?: string;
title?: string;
artist?: string;
album?: string;
year?: number;
}
/** Manual edits / an accepted match, sent to `PUT /tracks/{id}/metadata`. */
export interface MetadataEdit {
title?: string;
artistName?: string;
albumTitle?: string;
year?: number;
genre?: string;
trackNumber?: number;
}
+17 -8
View File
@@ -1,12 +1,13 @@
import { Badge, Tooltip } from 'modern-sk';
import { Badge, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { getApiBaseUrl } from '../../config/runtime-config';
const STATUS_LABELS = {
connected: 'Connected',
connecting: 'Connecting',
disconnected: 'Disconnected',
error: 'Connection error',
const STATUS_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
const STATUS_VARIANTS = {
@@ -17,13 +18,21 @@ const STATUS_VARIANTS = {
} as const;
export function ConnectionStatus() {
const { t } = useTranslation();
const status = useConnectionStatus();
const baseUrl = getApiBaseUrl();
const label = t(STATUS_KEY[status]);
// When the backend is unreachable the UI falls back to the persisted RTKQ
// cache (Tier 2), so flag that the data on screen is last-seen, not live.
const offline = status === 'disconnected' || status === 'error';
const tip = offline
? `${label} · ${baseUrl} · ${t('conn.cached')}`
: `${label} · ${baseUrl}`;
return (
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}>
<Tooltip content={tip}>
<Badge variant={STATUS_VARIANTS[status]} dot>
{STATUS_LABELS[status]}
{label}
</Badge>
</Tooltip>
);
+6 -7
View File
@@ -1,18 +1,17 @@
import { Callout, Button } from 'modern-sk';
import { Callout, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
}
export function ErrorState({
message = 'Something went wrong',
onRetry,
}: ErrorStateProps) {
export function ErrorState({ message, onRetry }: ErrorStateProps) {
const { t } = useTranslation();
return (
<div style={{ padding: '2rem' }}>
<Callout variant="danger">
{message}
{message ?? t('common.error')}
{onRetry && (
<Button
variant="ghost"
@@ -20,7 +19,7 @@ export function ErrorState({
onClick={onRetry}
style={{ marginLeft: '1rem' }}
>
Retry
{t('common.retry')}
</Button>
)}
</Callout>
+2 -2
View File
@@ -19,7 +19,7 @@ import {
GearSix,
HardDrives,
Heart,
House,
Info,
MagnifyingGlass,
Pause,
Play,
@@ -48,7 +48,6 @@ import {
const ICONS = {
'vinyl-record': VinylRecord,
house: House,
'magnifying-glass': MagnifyingGlass,
'arrow-circle-down': ArrowCircleDown,
'upload-simple': UploadSimple,
@@ -71,6 +70,7 @@ const ICONS = {
'skip-forward': SkipForward,
repeat: Repeat,
heart: Heart,
info: Info,
'thumbs-down': ThumbsDown,
'speaker-high': SpeakerHigh,
'speaker-x': SpeakerSimpleX,
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react';
import { Window } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
interface Props {
/** Window title. Pass an already-translated string. */
title: string;
/** Optional sub-line under the title; defaults to the shared "coming soon" copy. */
note?: string;
/** Optional extra content rendered below the note (e.g. a sub-nav). */
children?: ReactNode;
}
/**
* Scaffolding placeholder for screens that exist in the navigation map
* (music-selfhost-routes.md) but have no functionality yet. Keeps every
* stub visually consistent so filling one in later is mechanical.
*/
export function Placeholder({ title, note, children }: Props) {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title={title}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{note ?? t('common.comingSoon')}
</p>
{children}
</Window>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { NavLink } from 'react-router';
export interface SubNavItem {
to: string;
label: string;
/** Match the path exactly (used for index/redirect targets). */
end?: boolean;
}
interface Props {
items: SubNavItem[];
}
function subNavClass({ isActive }: { isActive: boolean }) {
return isActive ? 'sub-nav-item active' : 'sub-nav-item';
}
/** Horizontal secondary navigation for screens with sub-sections (Settings, Admin). */
export function SubNav({ items }: Props) {
return (
<nav className="sub-nav">
{items.map((it) => (
<NavLink key={it.to} to={it.to} end={it.end} className={subNavClass}>
{it.label}
</NavLink>
))}
</nav>
);
}
+12
View File
@@ -1,7 +1,10 @@
import { Outlet } from 'react-router';
import { Suspense } from 'react';
import { Sidebar } from './Sidebar';
import { PersistentPlayer } from '../player/PersistentPlayer';
import { QueuePanel } from '../player/QueuePanel';
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
import { LoadingSkeleton } from '../common/LoadingSkeleton';
export function AppShell() {
return (
@@ -17,10 +20,19 @@ export function AppShell() {
<Sidebar />
<main className="app-main">
<div className="app-screen">
<Suspense
fallback={
<div style={{ padding: '2rem' }}>
<LoadingSkeleton />
</div>
}
>
<Outlet />
</Suspense>
</div>
</main>
<QueuePanel />
<TrackInfoDrawer />
</div>
<PersistentPlayer />
</div>
+41 -29
View File
@@ -1,7 +1,8 @@
import { NavLink, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Icon, type IconName } from '../common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { usePermissions } from '../../hooks/usePermissions';
import { usePermissions, type Permission } from '../../hooks/usePermissions';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { logout } from '../../store/slices/auth';
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
@@ -9,24 +10,26 @@ import { getActiveInstance } from '../../config/instances';
interface NavDef {
to: string;
label: string;
labelKey: string;
icon: IconName;
end?: boolean;
/** Hide this item unless the user holds the permission. */
perm?: Permission;
}
const MAIN_NAV: NavDef[] = [
{ to: '/', label: 'Home', icon: 'house', end: true },
{ to: '/library', label: 'Library', icon: 'vinyl-record' },
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
{ to: '/storage', label: 'Storage', icon: 'hard-drives' },
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
];
const CONN_CLASS: Record<string, { cls: string; txt: string }> = {
connected: { cls: 'online', txt: 'Connected' },
connecting: { cls: 'syncing', txt: 'Connecting' },
disconnected: { cls: 'offline', txt: 'Offline' },
error: { cls: 'error', txt: 'Unreachable' },
const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
connected: { cls: 'online', txtKey: 'conn.connected' },
connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
error: { cls: 'error', txtKey: 'conn.error' },
};
function navClass({ isActive }: { isActive: boolean }) {
@@ -34,14 +37,15 @@ function navClass({ isActive }: { isActive: boolean }) {
}
export function Sidebar() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { user, isAdmin } = usePermissions();
const { user, isAdmin, hasPermission } = usePermissions();
const status = useConnectionStatus();
const { data: playlists } = useGetPlaylistsQuery();
const instance = getActiveInstance();
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
const online = status === 'connected';
const handleLogout = (e: React.MouseEvent) => {
@@ -59,20 +63,24 @@ export function Sidebar() {
</div>
<div className="sb-sec">
{MAIN_NAV.map(({ to, label, icon, end }) => (
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
({ to, labelKey, icon, end }) => (
<NavLink key={to} to={to} end={end} className={navClass}>
<Icon name={icon} />
<span>{label}</span>
<span>{t(labelKey)}</span>
</NavLink>
))}
),
)}
</div>
<div className="sb-sec">
<span className="msk-label">Playlists</span>
<NavLink to="/playlists" end className="msk-label sb-sec-link">
{t('nav.playlists')}
</NavLink>
{(playlists?.items ?? []).map((pl) => (
<NavLink
key={pl.id}
to={`/library/playlists/${pl.id}`}
to={`/playlists/${pl.id}`}
className={navClass}
>
<Icon name="playlist" />
@@ -82,30 +90,30 @@ export function Sidebar() {
<button
type="button"
className="pl-item"
onClick={() => void navigate('/library')}
onClick={() => void navigate('/playlists')}
>
<Icon name="plus" />
<span className="pl-name">New playlist</span>
<span className="pl-name">{t('nav.newPlaylist')}</span>
</button>
</div>
{isAdmin ? (
<div className="sb-sec">
<span className="msk-label">Administration</span>
<span className="msk-label">{t('nav.administration')}</span>
<NavLink to="/admin" className={navClass}>
<Icon name="shield-check" />
<span>Admin</span>
<span>{t('nav.admin')}</span>
</NavLink>
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
) : (
<div className="sb-sec">
<NavLink to="/settings" className={navClass}>
<Icon name="gear-six" />
<span>Settings</span>
<span>{t('nav.settings')}</span>
</NavLink>
</div>
)}
@@ -116,10 +124,10 @@ export function Sidebar() {
type="button"
className={`conn ${conn.cls}`}
onClick={() => void navigate('/connect')}
title="Connection — manage instances"
title={t('conn.manage')}
>
<span className="led" />
{conn.txt}
{t(conn.txtKey)}
</button>
{user && (
<button
@@ -133,10 +141,14 @@ export function Sidebar() {
<div className="user-meta">
<div className="nm">{user.username}</div>
<div className="rl">
{user.role} · {online ? 'online' : 'offline'}
{user.role} · {online ? t('user.online') : t('user.offline')}
</div>
</div>
<span className="uc-action" onClick={handleLogout} title="Sign out">
<span
className="uc-action"
onClick={handleLogout}
title={t('user.signOut')}
>
<Icon name="sign-out" />
</span>
</button>
+37 -24
View File
@@ -1,4 +1,5 @@
import { Slider } from 'modern-sk';
import { Slider } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -9,59 +10,72 @@ import {
setVolume,
toggleShuffle,
setRepeat,
toggleNowPlaying,
toggleQueue,
} from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
import { useStreamCached } from '../../hooks/useStreamCached';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
export function PersistentPlayer() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { seek, playNext, playPrev } = useAudioPlayer();
const player = useAppSelector((s) => s.player);
const queue = useAppSelector((s) => s.queue);
const token = useAppSelector((s) => s.auth.accessToken);
const currentEntry = queue.entries[queue.currentIndex];
// Read through to the live Track cache so enrichment updates reach the player,
// not just the play-time snapshot frozen in the queue slice.
const current = useResolvedQueueEntry(currentEntry);
// Source indicator: cached → playing locally, otherwise streaming.
const cached = useStreamCached(currentEntry?.trackId);
if (!currentEntry && !player.currentTrackId) {
return <div className="player empty">Nothing playing</div>;
return <div className="player empty">{t('player.nothingPlaying')}</div>;
}
const artUrl = getCoverUrl(currentEntry?.albumArtUrl);
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
// Streaming is the web default; local playback is a mobile-client concern.
const onStream = true;
const artUrl =
getCoverUrl(currentEntry?.albumArtUrl) ??
(token && current?.hasCover
? getTrackCoverUrl(current.trackId, token, true)
: undefined);
const seedLabel = current?.albumTitle ?? current?.title ?? '';
const onStream = !cached;
return (
<div className="player">
{/* now-playing identity */}
<div
className="pl-now"
onClick={() => dispatch(toggleNowPlaying())}
style={{ cursor: 'pointer' }}
onClick={() =>
currentEntry && dispatch(openTrackInfo(currentEntry.trackId))
}
style={{ cursor: currentEntry ? 'pointer' : 'default' }}
title={currentEntry ? t('trackInfo.open') : undefined}
>
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
<div className="pl-now-tt">
<div className="t">{currentEntry?.title ?? '—'}</div>
<div className="a">{currentEntry?.artistName ?? ''}</div>
<div className="t">{current?.title ?? '—'}</div>
<div className="a">{current?.artistName ?? ''}</div>
<div
className="pl-srcbadge"
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
>
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
{onStream ? t('player.streaming') : t('player.local')}
</div>
</div>
</div>
{/* transport + scrubber */}
<div className="pl-center">
<div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title="Shuffle"
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
@@ -69,7 +83,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playPrev}
title="Previous"
title={t('player.previous')}
>
<Icon name="skip-back" fill />
</button>
@@ -79,7 +93,7 @@ export function PersistentPlayer() {
onClick={() =>
player.isPlaying ? dispatch(pause()) : dispatch(resume())
}
title={player.isPlaying ? 'Pause' : 'Play'}
title={player.isPlaying ? t('player.pause') : t('player.play')}
>
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
</button>
@@ -87,7 +101,7 @@ export function PersistentPlayer() {
type="button"
className="pl-tbtn"
onClick={playNext}
title="Next"
title={t('player.next')}
>
<Icon name="skip-forward" fill />
</button>
@@ -105,7 +119,7 @@ export function PersistentPlayer() {
),
)
}
title={`Repeat: ${player.repeat}`}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
@@ -121,7 +135,7 @@ export function PersistentPlayer() {
step={1}
value={[player.position]}
onValueChange={([v]) => seek(v)}
aria-label="Seek"
aria-label={t('player.play')}
/>
<span className="pl-time">
{formatDuration(player.duration * 1000)}
@@ -129,13 +143,12 @@ export function PersistentPlayer() {
</div>
</div>
{/* volume + queue */}
<div className="pl-right">
<button
type="button"
className="pl-tbtn"
onClick={() => dispatch(toggleMute())}
title={player.muted ? 'Unmute' : 'Mute'}
title={player.muted ? t('player.unmute') : t('player.mute')}
>
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
</button>
@@ -154,7 +167,7 @@ export function PersistentPlayer() {
type="button"
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
onClick={() => dispatch(toggleQueue())}
title="Play queue"
title={t('player.queue')}
>
<Icon name="queue" />
</button>
+75 -43
View File
@@ -1,4 +1,5 @@
import { Slider, Badge } from 'modern-sk';
import { Slider, Badge } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
@@ -6,16 +7,21 @@ import {
goToIndex,
removeFromQueue,
clearQueue,
type QueueEntry,
} from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
export function QueuePanel() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const queue = useAppSelector((s) => s.queue);
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
const now =
const nowEntry =
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
const now = useResolvedQueueEntry(nowEntry);
const upNext = queue.entries
.map((entry, index) => ({ entry, index }))
.filter(({ index }) => index > queue.currentIndex);
@@ -27,13 +33,13 @@ export function QueuePanel() {
<div className="qd-inner">
<div className="qd-head">
<div className="row">
<h3>Play queue</h3>
<h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(clearQueue())}
title="Clear queue"
title={t('queue.clear')}
>
<Icon name="trash" />
</button>
@@ -41,7 +47,7 @@ export function QueuePanel() {
type="button"
className="iconbtn sm"
onClick={() => dispatch(toggleQueue())}
title="Close"
title={t('queue.close')}
>
<Icon name="x" />
</button>
@@ -53,10 +59,10 @@ export function QueuePanel() {
/>
{isRadio ? (
<span style={{ color: 'var(--lime)' }}>
Radio · {sourceLabel}
{t('queue.radio', { source: sourceLabel })}
</span>
) : (
<span>From {sourceLabel}</span>
<span>{t('queue.from', { source: sourceLabel })}</span>
)}
</div>
</div>
@@ -68,7 +74,7 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', marginBottom: 8 }}
>
Now playing
{t('queue.nowPlaying')}
</span>
<div className="qd-now">
<ArtTile
@@ -80,7 +86,14 @@ export function QueuePanel() {
<div className="t">{now.title}</div>
<div className="r">{now.artistName}</div>
</div>
<Icon name="cloud" style={{ color: 'var(--fg-3)' }} />
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(openTrackInfo(now.trackId))}
title={t('trackInfo.open')}
>
<Icon name="info" />
</button>
</div>
{isRadio && (
@@ -94,14 +107,13 @@ export function QueuePanel() {
color: 'var(--fg-1)',
}}
>
Radio active
{t('queue.radioActive')}
</span>
<div style={{ flex: 1 }} />
<Badge variant="neutral"> mixing</Badge>
<Badge variant="neutral">{t('queue.mixing')}</Badge>
</div>
{/* exploration balance — stub under the future ML contract */}
<div className="expl">
<span className="lab">Familiar</span>
<span className="lab">{t('queue.familiar')}</span>
<Slider
className="expl-slider"
min={0}
@@ -110,7 +122,7 @@ export function QueuePanel() {
defaultValue={[42]}
aria-label="Exploration"
/>
<span className="lab">New</span>
<span className="lab">{t('queue.new')}</span>
</div>
</div>
)}
@@ -119,51 +131,71 @@ export function QueuePanel() {
className="msk-label"
style={{ display: 'block', margin: '4px 0 8px' }}
>
Next up
{t('queue.nextUp')}
</span>
{upNext.length === 0 ? (
<div className="qd-empty">Nothing queued next</div>
<div className="qd-empty">{t('queue.nothingNext')}</div>
) : (
upNext.map(({ entry, index }) => (
<div
<QueueRow
key={`${entry.trackId}-${index}`}
className="qrow"
onDoubleClick={() => dispatch(goToIndex(index))}
title="Double-click to play"
>
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile
seed={entry.albumTitle}
size={36}
label={entry.albumTitle}
entry={entry}
onPlay={() => dispatch(goToIndex(index))}
onRemove={() => dispatch(removeFromQueue(index))}
/>
<div className="qt">
<div className="t">{entry.title}</div>
<div className="r">{entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={() => dispatch(removeFromQueue(index))}
title="Remove from queue"
>
<Icon name="x" />
</button>
</div>
))
)}
{isRadio && (
<div className="qd-loadmore">Loading more from radio</div>
<div className="qd-loadmore">{t('queue.loadingMore')}</div>
)}
</>
) : (
<div className="qd-empty">Queue is empty</div>
<div className="qd-empty">{t('queue.empty')}</div>
)}
</div>
</div>
</aside>
);
}
/** An "up next" row, resolving its display fields against the live Track cache
* (same read-through as the now-playing entry) so enrichment updates show. */
function QueueRow({
entry,
onPlay,
onRemove,
}: {
entry: QueueEntry;
onPlay: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const resolved = useResolvedQueueEntry(entry);
const albumTitle = resolved?.albumTitle ?? entry.albumTitle;
return (
<div
className="qrow"
onDoubleClick={onPlay}
title={t('queue.doubleClickPlay')}
>
<span className="grip">
<Icon name="dots-six-vertical" />
</span>
<ArtTile seed={albumTitle} size={36} label={albumTitle} />
<div className="qt">
<div className="t">{resolved?.title ?? entry.title}</div>
<div className="r">{resolved?.artistName ?? entry.artistName}</div>
</div>
<button
type="button"
className="iconbtn sm"
onClick={onRemove}
title={t('queue.removeFromQueue')}
>
<Icon name="x" />
</button>
</div>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { Badge, Tooltip } from 'modern-sk';
import { Badge, Tooltip } from '@olly/modern-sk';
import type { TrackAvailability } from '../../api/types';
interface Props {
@@ -0,0 +1,47 @@
import { Badge, Spinner, Tooltip } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import type { MetadataStatus } from '../../api/types';
interface Props {
status: MetadataStatus;
/** Reason shown in the tooltip for a `failed` status. */
error?: string;
/** When true, render nothing for the normal `enriched` state (keeps dense
* track lists quiet; the upload screen sets this false to confirm success). */
hideWhenEnriched?: boolean;
}
type Variant = 'lime' | 'ember' | 'neutral' | 'outline';
const VARIANT: Record<MetadataStatus, Variant> = {
pending: 'neutral',
enriched: 'lime',
failed: 'ember',
manual: 'outline',
};
/**
* Shows a track's metadata-enrichment state (distinct from file availability).
* `pending` carries a spinner; `failed` exposes the backend reason on hover.
*/
export function MetadataStatusBadge({
status,
error,
hideWhenEnriched = true,
}: Props) {
const { t } = useTranslation();
if (status === 'enriched' && hideWhenEnriched) return null;
const label = t(`metadata.status.${status}`);
const tooltip =
status === 'failed' && error ? error : t(`metadata.statusHint.${status}`);
return (
<Tooltip content={tooltip}>
<Badge variant={VARIANT[status]} dot={status !== 'pending'}>
{status === 'pending' ? <Spinner size="sm" /> : null}
{label}
</Badge>
</Tooltip>
);
}
+28 -9
View File
@@ -5,10 +5,12 @@ import {
MenuItem,
MenuSeparator,
IconButton,
} from 'modern-sk';
} from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
import { play } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui';
import type { Track } from '../../api/types';
interface Props {
@@ -26,6 +28,7 @@ export function TrackContextMenu({
onDelete,
onDownload,
}: Props) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entry = {
@@ -40,7 +43,11 @@ export function TrackContextMenu({
return (
<Menu>
<MenuTrigger asChild>
<IconButton variant="ghost" size="sm" aria-label="Track options">
<IconButton
variant="ghost"
size="sm"
aria-label={t('track.menu.options')}
>
</IconButton>
</MenuTrigger>
@@ -50,39 +57,51 @@ export function TrackContextMenu({
dispatch(play(track.id));
}}
>
Play now
{t('track.menu.playNow')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addNextInQueue(entry));
}}
>
Play next
{t('track.menu.playNext')}
</MenuItem>
<MenuItem
onSelect={() => {
dispatch(addToQueue(entry));
}}
>
Add to queue
{t('track.menu.addToQueue')}
</MenuItem>
<MenuSeparator />
<MenuItem
onSelect={() => {
dispatch(openTrackInfo(track.id));
}}
>
{t('track.menu.info')}
</MenuItem>
<MenuSeparator />
{onAddToPlaylist && (
<MenuItem onSelect={() => onAddToPlaylist(track)}>
Add to playlist
{t('track.menu.addToPlaylist')}
</MenuItem>
)}
<MenuSeparator />
{onEditMetadata && (
<MenuItem onSelect={() => onEditMetadata(track)}>
Edit metadata
{t('track.menu.editMetadata')}
</MenuItem>
)}
{onDownload && (
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
<MenuItem onSelect={() => onDownload(track)}>
{t('track.menu.download')}
</MenuItem>
)}
{onDelete && (
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
<MenuItem onSelect={() => onDelete(track)}>
{t('track.menu.delete')}
</MenuItem>
)}
</MenuContent>
</Menu>
+319
View File
@@ -0,0 +1,319 @@
import type { ReactNode } from 'react';
import { Badge, Button } from '@olly/modern-sk';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router';
import { skipToken } from '@reduxjs/toolkit/query';
import { Icon } from '../common/Icon';
import { ArtTile } from '../common/ArtTile';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { LoadingSkeleton } from '../common/LoadingSkeleton';
import { ErrorState } from '../common/ErrorState';
import { EmptyState } from '../common/EmptyState';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { closeTrackInfo } from '../../store/slices/ui';
import { play } from '../../store/slices/player';
import { addToQueue } from '../../store/slices/queue';
import {
useGetTrackQuery,
useGetAlbumQuery,
} from '../../api/endpoints/library';
import { getTrackCoverUrl } from '../../api/endpoints/streaming';
import {
formatDuration,
formatFileSize,
formatDateTime,
} from '../../lib/format';
import type { Track } from '../../api/types';
/**
* Right-side "Get Info"-style drawer for a single track. Rendered after the
* QueuePanel in AppShell so it sits to the *right* of the queue when both are
* open. Open state lives in `ui.trackInfoId`; it reads the live Track (and its
* album) from the RTKQ cache so enrichment updates stay in sync.
*/
export function TrackInfoDrawer() {
const trackId = useAppSelector((s) => s.ui.trackInfoId);
const isOpen = trackId !== null;
return (
<aside className={`tid${isOpen ? '' : ' closed'}`} aria-hidden={!isOpen}>
<div className="tid-inner">
{trackId ? <TrackInfoContent trackId={trackId} /> : null}
</div>
</aside>
);
}
function TrackInfoContent({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const token = useAppSelector((s) => s.auth.accessToken);
const {
data: track,
isLoading,
isError,
refetch,
} = useGetTrackQuery(trackId);
// Album record fills in fields the lean TrackOut omits (year especially).
const { data: album } = useGetAlbumQuery(track?.albumId ?? skipToken);
const close = () => dispatch(closeTrackInfo());
return (
<>
<div className="tid-head">
<h3>{t('trackInfo.title')}</h3>
<div style={{ flex: 1 }} />
<button
type="button"
className="iconbtn sm"
onClick={close}
title={t('trackInfo.close')}
>
<Icon name="x" />
</button>
</div>
<div className="tid-scroll">
{isLoading ? (
<LoadingSkeleton rows={6} />
) : isError ? (
<ErrorState onRetry={refetch} />
) : !track ? (
<EmptyState title={t('trackInfo.notFound')} />
) : (
<TrackInfoBody
track={track}
albumYear={album?.year}
albumTrackCount={album?.trackCount}
coverUrl={
token
? getTrackCoverUrl(track.id, token, track.hasCover)
: undefined
}
onPlay={() => dispatch(play(track.id))}
onQueue={() =>
dispatch(
addToQueue({
trackId: track.id,
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
durationMs: track.durationMs,
albumArtUrl: track.albumArtUrl,
}),
)
}
onEdit={() => {
navigate(`/tracks/${track.id}/metadata`);
}}
/>
)}
</div>
</>
);
}
function TrackInfoBody({
track,
albumYear,
albumTrackCount,
coverUrl,
onPlay,
onQueue,
onEdit,
}: {
track: Track;
albumYear?: number;
albumTrackCount?: number;
coverUrl?: string;
onPlay: () => void;
onQueue: () => void;
onEdit: () => void;
}) {
const { t } = useTranslation();
const seedLabel = track.albumTitle || track.title;
const year = track.year ?? albumYear;
return (
<>
<div className="tid-cover">
{coverUrl ? (
<img src={coverUrl} alt={track.albumTitle} />
) : (
<ArtTile seed={seedLabel} size={256} label={seedLabel} radius={12} />
)}
</div>
<h2 className="tid-title">{track.title}</h2>
<Link className="tid-sub" to={`/artists/${track.artistId}`}>
{track.artistName}
</Link>
{track.albumId && (
<Link className="tid-sub tid-album" to={`/albums/${track.albumId}`}>
<Icon name="vinyl-record" />
{track.albumTitle}
</Link>
)}
<div className="tid-actions">
<Button variant="primary" size="sm" onClick={onPlay}>
<Icon name="play" fill /> {t('trackInfo.play')}
</Button>
<Button variant="ghost" size="sm" onClick={onQueue}>
<Icon name="queue" /> {t('trackInfo.addToQueue')}
</Button>
<Button variant="ghost" size="sm" onClick={onEdit}>
{t('trackInfo.editMetadata')}
</Button>
</div>
<InfoSection title={t('trackInfo.sections.status')}>
<div className="tid-status">
<AvailabilityBadge availability={track.availability} />
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
hideWhenEnriched={false}
/>
{track.liked && (
<Badge variant="lime" dot>
{t('trackInfo.liked')}
</Badge>
)}
</div>
{track.metadataStatus === 'failed' && track.metadataError && (
<p className="tid-error">{track.metadataError}</p>
)}
</InfoSection>
<InfoSection title={t('trackInfo.sections.general')}>
<InfoRow
label={t('trackInfo.fields.artist')}
value={track.artistName}
/>
<InfoRow
label={t('trackInfo.fields.album')}
value={track.albumTitle || undefined}
/>
<InfoRow
label={t('trackInfo.fields.trackNumber')}
value={
track.trackNumber !== undefined
? albumTrackCount
? t('trackInfo.trackOf', {
n: track.trackNumber,
total: albumTrackCount,
})
: String(track.trackNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.disc')}
value={
track.discNumber !== undefined
? String(track.discNumber)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.year')}
value={year !== undefined ? String(year) : undefined}
/>
<InfoRow label={t('trackInfo.fields.genre')} value={track.genre} />
<InfoRow
label={t('trackInfo.fields.duration')}
value={formatDuration(track.durationMs)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.file')}>
<InfoRow
label={t('trackInfo.fields.format')}
value={track.format?.toUpperCase()}
/>
<InfoRow
label={t('trackInfo.fields.bitrate')}
value={
track.bitrate !== undefined
? t('trackInfo.kbps', { n: track.bitrate })
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.size')}
value={
track.fileSize !== undefined
? formatFileSize(track.fileSize)
: undefined
}
/>
<InfoRow
label={t('trackInfo.fields.source')}
value={track.source}
mono
/>
<InfoRow
label={t('trackInfo.fields.added')}
value={formatDateTime(track.createdAt)}
/>
<InfoRow
label={t('trackInfo.fields.enriched')}
value={formatDateTime(track.enrichedAt)}
/>
</InfoSection>
<InfoSection title={t('trackInfo.sections.identifiers')}>
<InfoRow label={t('trackInfo.fields.trackId')} value={track.id} mono />
<InfoRow
label={t('trackInfo.fields.albumId')}
value={track.albumId || undefined}
mono
/>
<InfoRow
label={t('trackInfo.fields.artistId')}
value={track.artistId}
mono
/>
</InfoSection>
</>
);
}
function InfoSection({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<section className="tid-section">
<span className="msk-label tid-section-label">{title}</span>
{children}
</section>
);
}
/** A label/value row; renders nothing when the value is empty (Finder-style). */
function InfoRow({
label,
value,
mono,
}: {
label: string;
value?: string;
mono?: boolean;
}) {
if (!value) return null;
return (
<div className="tid-row">
<span className="tid-row-k">{label}</span>
<span className={`tid-row-v${mono ? ' mono' : ''}`}>{value}</span>
</div>
);
}
+15 -3
View File
@@ -1,11 +1,12 @@
import { Row } from 'modern-sk';
import { Row } from '@olly/modern-sk';
import { TrackContextMenu } from './TrackContextMenu';
import { AvailabilityBadge } from './AvailabilityBadge';
import { MetadataStatusBadge } from './MetadataStatusBadge';
import { formatDuration } from '../../lib/format';
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
import { play } from '../../store/slices/player';
import type { Track } from '../../api/types';
import { getCoverUrl } from '../../api/endpoints/streaming';
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
interface Props {
track: Track;
@@ -27,8 +28,13 @@ export function TrackRow({
const dispatch = useAppDispatch();
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
const isPlaying = useAppSelector((s) => s.player.isPlaying);
const token = useAppSelector((s) => s.auth.accessToken);
const isActive = currentTrackId === track.id;
const artUrl = getCoverUrl(track.albumArtUrl);
// Prefer an explicit album art URL; otherwise serve the track's own cover
// (needs the token in the query string — `<img>` can't send a header).
const artUrl =
getCoverUrl(track.albumArtUrl) ??
(token ? getTrackCoverUrl(track.id, token, track.hasCover) : undefined);
return (
<Row
@@ -95,7 +101,13 @@ export function TrackRow({
{showAlbum && ` · ${track.albumTitle}`}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<MetadataStatusBadge
status={track.metadataStatus}
error={track.metadataError}
/>
<AvailabilityBadge availability={track.availability} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span
style={{
+40 -1
View File
@@ -1,2 +1,41 @@
/**
* Default backend base URL — the operator-set fallback used when no specific
* instance is active. Resolution order:
*
* 1. window.__APP_CONFIG__.apiBaseUrl — runtime, injected by the container
* at start from $PUBLIC_API_BASE_URL (see public/config.js). Lets one
* prebuilt image point at any backend origin without rebuilding.
* 2. import.meta.env.PUBLIC_API_BASE_URL — build-time default (rsbuild inlines
* PUBLIC_* vars). Used in local dev and as a baked fallback.
* 3. '/api/v1' — same-origin relative path (works behind a reverse proxy).
*
* The user's chosen instance still wins over all of these — see
* runtime-config.ts / instances.ts.
*/
function runtimeApiBaseUrl(): string | undefined {
if (typeof window === 'undefined') return undefined;
const value = window.__APP_CONFIG__?.apiBaseUrl;
return value ? value : undefined;
}
export const DEFAULT_API_BASE_URL =
import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
runtimeApiBaseUrl() ?? import.meta.env.PUBLIC_API_BASE_URL ?? '/api/v1';
/**
* Whether the public sign-up UI is shown. Same precedence as the base URL:
* runtime operator config (injected into `window.__APP_CONFIG__` at container
* start) wins over the build-time `PUBLIC_ENABLE_REGISTRATION` env, which
* defaults to enabled. This only gates the *UI*; the backend independently
* enforces `ALLOW_REGISTRATION` and is the real authority.
*/
function parseFlag(value: string | undefined): boolean | undefined {
if (value == null || value === '') return undefined;
return value !== 'false' && value !== '0';
}
export const REGISTRATION_ENABLED: boolean =
(typeof window !== 'undefined'
? window.__APP_CONFIG__?.enableRegistration
: undefined) ??
parseFlag(import.meta.env.PUBLIC_ENABLE_REGISTRATION) ??
true;
+11 -1
View File
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
const LEGACY_URL_KEY = 'mcma_api_base_url';
const LEGACY_AUTH_KEY = 'mcma_auth';
// The UI always talks to the `/api/v1` contract, so users only enter the
// origin (and optional reverse-proxy prefix). We append the contract path
// here, the single choke point for both the base URL and the instance id, so
// `domain.com`, `domain.com/`, and `domain.com/api/v1` all converge.
function normalizeUrl(url: string): string {
return url.trim().replace(/\/+$/, '');
const trimmed = url.trim().replace(/\/+$/, '');
return /\/api\/v1$/.test(trimmed) ? trimmed : `${trimmed}/api/v1`;
}
/** Stable, readable id from a base URL — also serves as the storage namespace. */
@@ -93,6 +98,11 @@ export function upsertInstance(url: string, name?: string): Instance {
return inst;
}
/** Clear a backend's stored session without forgetting the instance itself. */
export function clearInstanceAuth(id: string): void {
localStorage.removeItem(scopedKey('auth', id));
}
/** Remove a backend and wipe every scoped key it owns. */
export function removeInstance(id: string): void {
writeRegistry(readRegistry().filter((i) => i.id !== id));
+10
View File
@@ -1,7 +1,17 @@
/// <reference types="@rsbuild/core/types" />
interface ImportMetaEnv {
readonly PUBLIC_API_BASE_URL?: string;
readonly PUBLIC_ENABLE_REGISTRATION?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Runtime operator config injected by /config.js before the app bundle loads
// (written from $PUBLIC_API_BASE_URL at container start). See src/config/env.ts.
interface Window {
__APP_CONFIG__?: {
apiBaseUrl?: string;
enableRegistration?: boolean;
};
}
+27 -5
View File
@@ -1,10 +1,32 @@
import { Window } from 'modern-sk';
import { Outlet } from 'react-router';
import { useTranslation } from 'react-i18next';
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
/**
* `/admin` — A9 admin shell (admin-gated). Secondary nav + nested `<Outlet/>`
* for users/sources/instance. `/admin` redirects to `/admin/users`.
*/
export function AdminPage() {
const { t } = useTranslation();
const items: SubNavItem[] = [
{ to: '/admin/users', label: t('admin.tabs.users') },
{ to: '/admin/sources', label: t('admin.tabs.sources') },
{ to: '/admin/instance', label: t('admin.tabs.instance') },
];
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Admin">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1 className="page-title">{t('pages.admin')}</h1>
<SubNav items={items} />
<Outlet />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
function StubPanel({ title }: { title: string }) {
const { t } = useTranslation();
return (
<Window title={title}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/admin/users` — user list (add/remove). Scaffold. */
export function AdminUsers() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.users')} />;
}
/** `/admin/users/:userId` — per-user permissions / reset password / status. Scaffold. */
export function AdminUserDetail() {
const { t } = useTranslation();
return <StubPanel title={t('admin.userDetail')} />;
}
/** `/admin/sources` — pluggable source management (creds/cookies/status). Scaffold. */
export function AdminSources() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.sources')} />;
}
/** `/admin/instance` — service health, ML_SERVICE_URL, reindex. Scaffold. */
export function AdminInstance() {
const { t } = useTranslation();
return <StubPanel title={t('admin.tabs.instance')} />;
}
+11 -11
View File
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetAlbumQuery,
useGetAlbumTracksQuery,
@@ -14,6 +15,7 @@ import { formatDuration } from '../../lib/format';
import { getCoverUrl } from '../../api/endpoints/streaming';
export function AlbumDetailPage() {
const { t } = useTranslation();
const { albumId } = useParams<{ albumId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -32,7 +34,7 @@ export function AlbumDetailPage() {
if (albumQuery.isError) {
return (
<ErrorState
message="Failed to load album"
message={t('album.error')}
onRetry={() => albumQuery.refetch()}
/>
);
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* header */}
<div
style={{
padding: '1.25rem 1.5rem',
@@ -78,7 +79,7 @@ export function AlbumDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
letterSpacing: '0.05em',
}}
>
Album
{t('album.type')}
</p>
<h1
style={{
@@ -146,7 +147,7 @@ export function AlbumDetailPage() {
{album?.artistName}
{album?.year && ` · ${album.year}`}
{album &&
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
` · ${album.trackCount} · ${formatDuration(album.totalDurationMs)}`}
</p>
</div>
</div>
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('album.play')}
</Button>
</div>
{/* tracks */}
<ScrollArea style={{ flex: 1 }}>
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('album.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="This album has no tracks."
title={t('album.empty.title')}
description={t('album.empty.description')}
/>
)}
{tracks.map((track, i) => (
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/artists/:artistId` — A3 artist detail (discography + similar). Scaffold only. */
export function ArtistDetailPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.artist')} />;
}
+8
View File
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/login` — sign in when the instance is already chosen (B1-for-web). Scaffold only. */
export function LoginPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.login')} />;
}
+342 -107
View File
@@ -1,64 +1,273 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { Card, TextField, Button, Callout, Badge } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
import {
Card,
TextField,
Button,
Callout,
Badge,
Dialog,
IconButton,
} from '@olly/modern-sk';
import { Icon } from '../../components/common/Icon';
import { useAppDispatch } from '../../hooks/useAppDispatch';
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
import { setTokens, setUser } from '../../store/slices/auth';
import { setApiBaseUrl } from '../../config/runtime-config';
import {
useLoginMutation,
useRegisterMutation,
} from '../../api/endpoints/auth';
import { REGISTRATION_ENABLED } from '../../config/env';
import {
listInstances,
getActiveInstanceId,
setActiveInstanceId,
removeInstance,
clearInstanceAuth,
upsertInstance,
type Instance,
} from '../../config/instances';
import type { User } from '../../api/types';
type Mode = 'login' | 'register';
const HEALTH_VARIANTS = {
connected: 'lime',
connecting: 'neutral',
disconnected: 'ember',
error: 'ember',
} as const;
const HEALTH_KEY = {
connected: 'conn.connected',
connecting: 'conn.connecting',
disconnected: 'conn.disconnected',
error: 'conn.error',
} as const;
/** Map an RTKQ login failure to a user-facing i18n key. */
function resolveLoginError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 401) return 'connect.errors.badCredentials';
}
return 'connect.errors.generic';
}
/** Map an RTKQ register failure to a user-facing i18n key. */
function resolveRegisterError(err: unknown): string {
const e = err as FetchBaseQueryError | undefined;
if (e && 'status' in e) {
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
if (e.status === 409) return 'connect.errors.usernameTaken';
if (e.status === 422) return 'connect.errors.passwordTooShort';
if (e.status === 403) return 'connect.errors.registrationDisabled';
}
return 'connect.errors.registerFailed';
}
function InstanceRow({
inst,
selected,
onSelect,
onLogout,
onRemove,
}: {
inst: Instance;
selected: boolean;
onSelect: () => void;
onLogout: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation();
const status = useConnectionStatus(inst.baseUrl);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
}}
>
<Badge variant={HEALTH_VARIANTS[status]} dot>
{t(HEALTH_KEY[status])}
</Badge>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{selected ? (
<Badge variant="outline">{t('connect.domains.selected')}</Badge>
) : (
<Button variant="ghost" size="sm" onClick={onSelect}>
{t('connect.domains.use')}
</Button>
)}
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t('connect.removeDialog.title')}
description={t('connect.removeDialog.description', {
name: inst.name,
})}
trigger={
<button
type="button"
className="iconbtn sm"
title={t('connect.domains.forgetTitle')}
>
<Icon name="trash" />
</button>
}
footer={
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '0.5rem',
}}
>
<Button
variant="ghost"
size="sm"
onClick={() => setDialogOpen(false)}
>
{t('connect.removeDialog.cancel')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setDialogOpen(false);
onLogout();
}}
>
{t('connect.removeDialog.logout')}
</Button>
<Button
variant="ember"
size="sm"
onClick={() => {
setDialogOpen(false);
onRemove();
}}
>
{t('connect.removeDialog.removeAndLogout')}
</Button>
</div>
}
/>
</div>
);
}
export function ConnectPage() {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const navigate = useNavigate();
// Re-read on each render trigger; instance ops below force a remount via state.
const [rev, setRev] = useState(0);
const instances = listInstances();
const activeId = getActiveInstanceId();
const [apiUrl, setApiUrl] = useState('https://');
const [selectedId, setSelectedId] = useState<string | null>(() =>
getActiveInstanceId(),
);
const selectedInstance = instances.find((i) => i.id === selectedId) ?? null;
const [instanceAddShown, setInstanceAddShown] = useState(false);
const [addUrl, setAddUrl] = useState('');
const [mode, setMode] = useState<Mode>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
// Switching to a saved backend reloads the app so every slice re-initialises
// from that instance's namespaced storage (its own session, prefs, cache).
const switchTo = (id: string) => {
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
const [register, { isLoading: isRegistering }] = useRegisterMutation();
const isLoading = isLoggingIn || isRegistering;
const switchMode = (next: Mode) => {
setMode(next);
setError(null);
};
// Switching the active instance and reloading lets the app pick the saved
// session for that instance back up (if any); if it has none, ProtectedRoute
// bounces back here and `selectedId` defaults to it, surfacing the login card.
const selectInstance = (id: string) => {
setActiveInstanceId(id);
window.location.assign('/');
};
const forget = (id: string) => {
removeInstance(id);
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const url = addUrl.trim();
if (!url || url === 'https://') return;
const inst = upsertInstance(url);
setActiveInstanceId(inst.id);
setAddUrl('https://');
setSelectedId(inst.id);
setRev((r) => r + 1);
};
// STUB: no backend yet. Register the instance, then fake a session so the rest
// of the app is reachable. Replace with the real useLoginMutation() flow later.
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setApiBaseUrl(apiUrl); // upsert + activate this backend
const fakeUser: User = {
id: 'dev-user',
username: username || 'dev',
role: 'admin',
createdAt: new Date().toISOString(),
const handleLogout = (id: string) => {
clearInstanceAuth(id);
setRev((r) => r + 1);
};
dispatch(
setTokens({
accessToken: 'dev-token',
refreshToken: 'dev-refresh',
expiresIn: 3600,
}),
);
dispatch(setUser(fakeUser));
const handleRemove = (id: string) => {
removeInstance(id);
if (selectedId === id) setSelectedId(getActiveInstanceId());
setRev((r) => r + 1);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedInstance) return;
setError(null);
try {
const action =
mode === 'register'
? register({ username, password })
: login({ username, password });
const { user, tokens } = await action.unwrap();
dispatch(setTokens(tokens));
dispatch(setUser(user));
void navigate('/');
} catch (err) {
setError(
mode === 'register'
? resolveRegisterError(err)
: resolveLoginError(err),
);
}
};
const labelStyle: React.CSSProperties = {
@@ -103,7 +312,6 @@ export function ConnectPage() {
<Icon name="vinyl-record" fill /> MCMA
</h1>
{instances.length > 0 && (
<Card>
<div
style={{
@@ -113,82 +321,57 @@ export function ConnectPage() {
gap: '0.5rem',
}}
>
{instances.length > 0 && (
<>
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
Saved instances
{t('connect.domains.title')}
</span>
{instances.map((inst) => (
<div
<InstanceRow
key={inst.id}
inst={inst}
selected={inst.id === selectedId}
onSelect={() => selectInstance(inst.id)}
onLogout={() => handleLogout(inst.id)}
onRemove={() => handleRemove(inst.id)}
/>
))}
</>
)}
<form
onSubmit={handleAdd}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.625rem',
padding: '0.375rem 0',
gap: '0.5rem',
marginTop: instances.length > 0 ? '0.5rem' : 0,
}}
>
<span
className={`led ${inst.id === activeId ? 'online' : 'offline'}`}
style={{
width: 8,
height: 8,
borderRadius: '50%',
background:
inst.id === activeId ? 'var(--lime)' : 'var(--fg-3)',
boxShadow:
inst.id === activeId ? '0 0 6px var(--lime)' : 'none',
flexShrink: 0,
}}
{instanceAddShown ? (
<>
<TextField
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
placeholder={t('connect.domains.addPlaceholder')}
style={{ flex: 1 }}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--color-text-1)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.name}
</div>
<div
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{inst.baseUrl}
</div>
</div>
{inst.id === activeId ? (
<Badge variant="lime">active</Badge>
<IconButton type="submit" variant="primary">
<Icon name="plus" />
</IconButton>
</>
) : (
<Button
onClick={() => setInstanceAddShown(true)}
style={{ width: '100%' }}
variant="ghost"
size="sm"
onClick={() => switchTo(inst.id)}
>
Use
<Icon name="plus" /> {t('connect.domains.addButton')}
</Button>
)}
<button
type="button"
className="iconbtn sm"
onClick={() => forget(inst.id)}
title="Forget this instance"
>
<Icon name="trash" />
</button>
</div>
))}
</form>
</div>
</Card>
)}
{selectedInstance && (
<Card>
<form
onSubmit={handleSubmit}
@@ -199,18 +382,15 @@ export function ConnectPage() {
padding: '1.5rem',
}}
>
<span className="msk-label">Connect to a backend</span>
<span className="msk-label">
{mode === 'register'
? t('connect.login.registerTitle', {
name: selectedInstance.name,
})
: t('connect.login.title', { name: selectedInstance.name })}
</span>
<div>
<label style={labelStyle}>Server URL</label>
<TextField
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://your-server.example.com/api/v1"
required
/>
</div>
<div>
<label style={labelStyle}>Username</label>
<label style={labelStyle}>{t('connect.login.username')}</label>
<TextField
value={username}
onChange={(e) => setUsername(e.target.value)}
@@ -220,29 +400,84 @@ export function ConnectPage() {
/>
</div>
<div>
<label style={labelStyle}>Password</label>
<label style={labelStyle}>{t('connect.login.password')}</label>
<TextField
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
autoComplete="current-password"
autoComplete={
mode === 'register' ? 'new-password' : 'current-password'
}
required
/>
{mode === 'register' && (
<span
style={{
display: 'block',
fontSize: '0.75rem',
color: 'var(--color-text-3)',
marginTop: '0.375rem',
}}
>
{t('connect.login.passwordHint')}
</span>
)}
</div>
<Callout variant="warning">
Stub mode backend not wired. Connect signs in with a fake admin
session, scoped to this instance.
</Callout>
{error && <Callout variant="danger">{t(error)}</Callout>}
<Button
type="submit"
variant="primary"
disabled={isLoading}
style={{ marginTop: '0.5rem' }}
>
Connect
{isLoading
? mode === 'register'
? t('connect.login.registering')
: t('connect.login.submitting')
: mode === 'register'
? t('connect.login.registerSubmit')
: t('connect.login.submit')}
</Button>
{REGISTRATION_ENABLED && (
<div
style={{
textAlign: 'center',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
}}
>
{mode === 'register' ? (
<>
{t('connect.login.haveAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('login')}
>
{t('connect.login.signInLink')}
</Button>
</>
) : (
<>
{t('connect.login.noAccount')}{' '}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => switchMode('register')}
>
{t('connect.login.registerLink')}
</Button>
</>
)}
</div>
)}
</form>
</Card>
)}
</div>
</div>
);
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function DownloadsManagerPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Downloads">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.downloads')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
-444
View File
@@ -1,444 +0,0 @@
import { useState } from 'react';
import {
Button,
IconButton,
TextField,
TextArea,
SearchField,
Select,
Switch,
Checkbox,
RadioGroup,
RadioItem,
Control,
SegmentedControl,
Slider,
Stepper,
Tabs,
TabsList,
TabsContent,
Progress,
Badge,
Chip,
Card,
List,
Row,
Menu,
MenuTrigger,
MenuContent,
MenuItem,
MenuSeparator,
Tooltip,
Spinner,
Callout,
Table,
THead,
TBody,
Tr,
Th,
Td,
Dialog,
DialogClose,
AlertDialog,
Window,
useTheme,
} from 'modern-sk';
const sectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
};
const rowWrap: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
};
const labelStyle: React.CSSProperties = {
fontSize: '0.75rem',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: 'var(--color-text-3)',
};
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card style={{ padding: '1.25rem', ...sectionStyle }}>
<span style={labelStyle}>{title}</span>
{children}
</Card>
);
}
export function HomePage() {
const { theme, setTheme } = useTheme();
const [search, setSearch] = useState('');
const [select, setSelect] = useState<string | undefined>();
const [seg, setSeg] = useState('list');
const [tab, setTab] = useState('one');
const [vol, setVol] = useState([60]);
const [count, setCount] = useState(3);
const [chips, setChips] = useState(['rock', 'jazz', 'ambient']);
const [switchOn, setSwitchOn] = useState(true);
const [radio, setRadio] = useState('a');
return (
<div style={{ overflow: 'auto', height: '100%' }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: '64rem',
margin: '0 auto',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<h1 style={{ margin: 0, fontSize: '1.5rem', fontWeight: 700 }}>
MCMA Component Kitchen Sink
</h1>
<p
style={{
margin: '0.25rem 0 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
modern-sk reference. Project base ready for development.
</p>
</div>
<Tooltip content={`Switch to ${theme === 'dark' ? 'light' : 'dark'}`}>
<Button
variant="ghost"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? '☾ Dark' : '☀ Light'}
</Button>
</Tooltip>
</div>
<Section title="Buttons">
<div style={rowWrap}>
<Button variant="key">Key</Button>
<Button variant="primary">Primary</Button>
<Button variant="ember">Ember</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="primary" size="sm">
Small
</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
<div style={rowWrap}>
<IconButton variant="primary" aria-label="Play">
</IconButton>
<IconButton variant="ghost" aria-label="Next">
</IconButton>
<IconButton variant="ember" size="lg" aria-label="Stop">
</IconButton>
<Stepper
onDecrement={() => setCount((c) => c - 1)}
onIncrement={() => setCount((c) => c + 1)}
/>
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}
>
count: {count}
</span>
</div>
</Section>
<Section title="Inputs">
<div style={rowWrap}>
<TextField placeholder="Text field" style={{ width: '14rem' }} />
<SearchField
icon="⌕"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
style={{ width: '14rem' }}
/>
<Select
placeholder="Pick genre"
aria-label="Genre"
value={select}
onValueChange={setSelect}
items={[
{ value: 'rock', label: 'Rock' },
{ value: 'jazz', label: 'Jazz' },
{ value: 'ambient', label: 'Ambient' },
]}
/>
</div>
<TextArea placeholder="Text area / description…" rows={3} />
</Section>
<Section title="Toggles & selection">
<div style={rowWrap}>
<Control
control={
<Switch checked={switchOn} onCheckedChange={setSwitchOn} />
}
>
Switch
</Control>
<Control control={<Checkbox defaultChecked />}>Checkbox</Control>
</div>
<RadioGroup
value={radio}
onValueChange={setRadio}
style={{ display: 'flex', gap: '1rem' }}
>
<Control control={<RadioItem value="a" />}>Option A</Control>
<Control control={<RadioItem value="b" />}>Option B</Control>
<Control control={<RadioItem value="c" />}>Option C</Control>
</RadioGroup>
<SegmentedControl
value={seg}
onValueChange={setSeg}
items={[
{ value: 'list', label: 'List' },
{ value: 'grid', label: 'Grid' },
{ value: 'compact', label: 'Compact' },
]}
/>
</Section>
<Section title="Sliders & progress">
<Slider
min={0}
max={100}
step={1}
value={vol}
onValueChange={setVol}
notches="bottom"
/>
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-2)' }}>
value: {vol[0]}
</span>
<Progress value={vol[0]} />
</Section>
<Section title="Badges, chips, spinner">
<div style={rowWrap}>
<Badge variant="lime" dot>
On server
</Badge>
<Badge variant="ember" dot>
Error
</Badge>
<Badge variant="neutral">Neutral</Badge>
<Badge variant="outline">Outline</Badge>
<Spinner size="sm" />
<Spinner size="lg" />
</div>
<div style={rowWrap}>
{chips.map((c) => (
<Chip
key={c}
onRemove={() => setChips((prev) => prev.filter((x) => x !== c))}
>
{c}
</Chip>
))}
{chips.length === 0 && (
<span
style={{ fontSize: '0.875rem', color: 'var(--color-text-3)' }}
>
all removed
</span>
)}
</div>
</Section>
<Section title="Callouts">
<Callout variant="info">
Info backend address resolves from runtime env relative
/api/v1.
</Callout>
<Callout variant="success">
Success typecheck and lint pass clean.
</Callout>
<Callout variant="warning">
Warning most feature screens are still stubs.
</Callout>
<Callout variant="danger">
Danger destructive actions use AlertDialog.
</Callout>
</Section>
<Section title="Tabs">
<Tabs value={tab} onValueChange={setTab}>
<TabsList
items={[
{ value: 'one', label: 'First' },
{ value: 'two', label: 'Second' },
{ value: 'three', label: 'Third' },
]}
/>
<TabsContent
value="one"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
First panel
</TabsContent>
<TabsContent
value="two"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Second panel
</TabsContent>
<TabsContent
value="three"
style={{
padding: '0.75rem 0',
color: 'var(--color-text-2)',
fontSize: '0.875rem',
}}
>
Third panel
</TabsContent>
</Tabs>
</Section>
<Section title="List & rows">
<List>
<Row style={{ padding: '0.5rem 0.75rem' }}>Track one Artist</Row>
<Row selected style={{ padding: '0.5rem 0.75rem' }}>
Track two Artist (selected)
</Row>
<Row style={{ padding: '0.5rem 0.75rem' }}>
Track three Artist
</Row>
</List>
</Section>
<Section title="Table">
<Table>
<THead>
<Tr>
<Th>Title</Th>
<Th>Artist</Th>
<Th>Duration</Th>
</Tr>
</THead>
<TBody>
<Tr>
<Td>Intro</Td>
<Td>Aphex</Td>
<Td>2:14</Td>
</Tr>
<Tr selected>
<Td>Windowlicker</Td>
<Td>Aphex</Td>
<Td>6:07</Td>
</Tr>
<Tr>
<Td>Avril 14th</Td>
<Td>Aphex</Td>
<Td>2:01</Td>
</Tr>
</TBody>
</Table>
</Section>
<Section title="Menu, Dialog, AlertDialog">
<div style={rowWrap}>
<Menu>
<MenuTrigger asChild>
<Button variant="ghost">Open menu </Button>
</MenuTrigger>
<MenuContent>
<MenuItem>Play</MenuItem>
<MenuItem shortcut="⌘N">Add to queue</MenuItem>
<MenuSeparator />
<MenuItem>Edit metadata</MenuItem>
</MenuContent>
</Menu>
<Dialog
trigger={<Button variant="primary">Open dialog</Button>}
title="Dialog title"
description="Composed from modern-sk primitives."
footer={
<DialogClose asChild>
<Button variant="primary">Done</Button>
</DialogClose>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Dialog body content.
</p>
</Dialog>
<AlertDialog
trigger={<Button variant="ember">Delete</Button>}
title="Delete track?"
description="This permanently removes the file from the server."
actionLabel="Delete"
destructive
onAction={() => undefined}
/>
</div>
</Section>
<Section title="Window">
<Window
title="Now Playing"
badge={
<Badge variant="lime" dot>
live
</Badge>
}
>
<p
style={{
color: 'var(--color-text-2)',
fontSize: '0.875rem',
margin: 0,
}}
>
Window chrome for grouped content.
</p>
</Window>
</Section>
</div>
</div>
);
}
+27 -17
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
Tabs,
TabsList,
@@ -7,7 +8,7 @@ import {
SearchField,
ScrollArea,
Card,
} from 'modern-sk';
} from '@olly/modern-sk';
import {
useGetTracksQuery,
useGetAlbumsQuery,
@@ -24,6 +25,7 @@ import { getCoverUrl } from '../../api/endpoints/streaming';
import { formatDuration } from '../../lib/format';
export function LibraryPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [tab, setTab] = useState('tracks');
@@ -45,7 +47,7 @@ export function LibraryPage() {
albumArtUrl: t.albumArtUrl,
})),
source: 'manual',
sourceName: 'Library',
sourceName: t('library.title'),
}),
);
};
@@ -63,13 +65,13 @@ export function LibraryPage() {
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
Library
{t('library.title')}
</h2>
<div style={{ flex: 1, maxWidth: '20rem' }}>
<SearchField
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search library…"
placeholder={t('library.searchPlaceholder')}
icon="⌕"
/>
</div>
@@ -94,9 +96,9 @@ export function LibraryPage() {
>
<TabsList
items={[
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
{ value: 'tracks', label: t('library.tabs.tracks') },
{ value: 'albums', label: t('library.tabs.albums') },
{ value: 'artists', label: t('library.tabs.artists') },
]}
/>
</div>
@@ -110,8 +112,8 @@ export function LibraryPage() {
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
<EmptyState
icon="♫"
title="No tracks"
description="Your library is empty. Start by downloading some music."
title={t('library.empty.tracks.title')}
description={t('library.empty.tracks.description')}
/>
)}
{tracksQuery.data &&
@@ -140,7 +142,7 @@ export function LibraryPage() {
fontWeight: 500,
}}
>
Play all ({data.total})
{t('library.playAll', { count: data.total })}
</button>
</div>
{data.items.map((track, i) => (
@@ -166,8 +168,8 @@ export function LibraryPage() {
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
<EmptyState
icon="💿"
title="No albums"
description="No albums in library."
title={t('library.empty.albums.title')}
description={t('library.empty.albums.description')}
/>
)}
{albumsQuery.data && (
@@ -183,7 +185,7 @@ export function LibraryPage() {
<AlbumCard
key={album.id}
album={album}
onClick={() => void navigate(`/library/albums/${album.id}`)}
onClick={() => void navigate(`/albums/${album.id}`)}
/>
))}
</div>
@@ -200,8 +202,8 @@ export function LibraryPage() {
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
<EmptyState
icon="🎤"
title="No artists"
description="No artists in library."
title={t('library.empty.artists.title')}
description={t('library.empty.artists.description')}
/>
)}
{artistsQuery.data && (
@@ -219,6 +221,7 @@ export function LibraryPage() {
}
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
const { t } = useTranslation();
const artUrl = getCoverUrl(album.artUrl);
return (
<Card
@@ -282,7 +285,10 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
{album.artistName}
</div>
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
{album.trackCount} tracks · {formatDuration(album.totalDurationMs)}
{t('library.albumCard.tracksDuration', {
count: album.trackCount,
duration: formatDuration(album.totalDurationMs),
})}
</div>
</div>
</Card>
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
}
function ArtistRow({ artist }: { artist: Artist }) {
const { t } = useTranslation();
return (
<div
style={{
@@ -319,7 +326,10 @@ function ArtistRow({ artist }: { artist: Artist }) {
{artist.name}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{artist.albumCount} albums · {artist.trackCount} tracks
{t('library.artistRow.meta', {
albumCount: artist.albumCount,
trackCount: artist.trackCount,
})}
</div>
</div>
</div>
@@ -0,0 +1,517 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Badge, Button, Callout, Card, IconButton, ScrollArea, Spinner, TextField } from '@olly/modern-sk';
import {
useApplyMetadataMutation,
useEnrichTrackMutation,
useGetTrackQuery,
useLazyGetMetadataMatchesQuery,
} from '../../api/endpoints/library';
import type { MetadataMatch } from '../../api/types';
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
import { ErrorState } from '../../components/common/ErrorState';
import { Placeholder } from '../../components/common/Placeholder';
interface Props {
/** Single-track editor vs. batch editor — both A7, same scaffold. */
batch?: boolean;
}
/** Editable fields, kept as strings while in the form — parsed on save. */
interface FormState {
title: string;
artistName: string;
albumTitle: string;
year: string;
genre: string;
trackNumber: string;
}
const EMPTY_FORM: FormState = {
title: '',
artistName: '',
albumTitle: '',
year: '',
genre: '',
trackNumber: '',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 500,
marginBottom: '0.375rem',
color: 'var(--color-text-2)',
};
function fieldStyle(): React.CSSProperties {
return { width: '100%' };
}
/**
* `/tracks/:trackId/metadata` — A7 metadata editor: manual edits + AcoustID
* match picker with a current-vs-proposed diff. `/metadata/batch` is deferred.
*/
export function MetadataEditorPage({ batch = false }: Props) {
const { t } = useTranslation();
if (batch) {
return <Placeholder title={t('pages.metadataBatch')} />;
}
return <SingleTrackEditor />;
}
function SingleTrackEditor() {
const { t } = useTranslation();
const navigate = useNavigate();
const { trackId } = useParams<{ trackId: string }>();
const trackQuery = useGetTrackQuery(trackId ?? '', { skip: !trackId });
const [findMatches, matchesResult] = useLazyGetMetadataMatchesQuery();
const [applyMetadata, applyResult] = useApplyMetadataMutation();
const [enrichTrack, enrichResult] = useEnrichTrackMutation();
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [initialized, setInitialized] = useState(false);
const [selectedMatch, setSelectedMatch] = useState<MetadataMatch | null>(null);
// Seed the form from the loaded track exactly once — afterwards it's the
// user's edit buffer and shouldn't be clobbered by refetches.
useEffect(() => {
if (initialized || !trackQuery.data) return;
const track = trackQuery.data;
setForm({
title: track.title,
artistName: track.artistName,
albumTitle: track.albumTitle,
year: track.year != null ? String(track.year) : '',
genre: track.genre ?? '',
trackNumber: track.trackNumber != null ? String(track.trackNumber) : '',
});
setInitialized(true);
}, [initialized, trackQuery.data]);
if (!trackId) {
return <ErrorState message={t('metadataEditor.error')} />;
}
if (trackQuery.isLoading || !initialized) {
return (
<div style={{ padding: '1.5rem' }}>
<LoadingSkeleton rows={6} />
</div>
);
}
if (trackQuery.isError || !trackQuery.data) {
return (
<ErrorState
message={t('metadataEditor.error')}
onRetry={() => trackQuery.refetch()}
/>
);
}
const track = trackQuery.data;
const updateField = (key: keyof FormState) => (value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const applyMatch = (match: MetadataMatch) => {
setForm((prev) => ({
...prev,
title: match.title ?? prev.title,
artistName: match.artist ?? prev.artistName,
albumTitle: match.album ?? prev.albumTitle,
year: match.year != null ? String(match.year) : prev.year,
}));
setSelectedMatch(null);
};
const handleSave = async () => {
await applyMetadata({
trackId,
edit: {
title: form.title.trim() || undefined,
artistName: form.artistName.trim() || undefined,
albumTitle: form.albumTitle.trim() || undefined,
year: form.year.trim() ? Number(form.year) : undefined,
genre: form.genre.trim() || undefined,
trackNumber: form.trackNumber.trim() ? Number(form.trackNumber) : undefined,
},
}).unwrap();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
flexShrink: 0,
}}
>
<IconButton
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label={t('common.back')}
>
</IconButton>
<div style={{ flex: 1, minWidth: 0 }}>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('pages.metadata')}
</h2>
<p
style={{
margin: '0.125rem 0 0',
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{track.artistName} · {track.title}
</p>
</div>
</div>
<ScrollArea style={{ flex: 1 }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
maxWidth: 640,
}}
>
{applyResult.isSuccess && (
<Callout variant="success">{t('metadataEditor.saved')}</Callout>
)}
{applyResult.isError && (
<Callout variant="danger">{t('metadataEditor.saveError')}</Callout>
)}
<Card>
<div
style={{
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<div>
<label style={labelStyle}>{t('metadataEditor.fields.title')}</label>
<TextField
style={fieldStyle()}
value={form.title}
onChange={(e) => updateField('title')(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>{t('metadataEditor.fields.artist')}</label>
<TextField
style={fieldStyle()}
value={form.artistName}
onChange={(e) => updateField('artistName')(e.target.value)}
/>
</div>
<div>
<label style={labelStyle}>{t('metadataEditor.fields.album')}</label>
<TextField
style={fieldStyle()}
value={form.albumTitle}
onChange={(e) => updateField('albumTitle')(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>{t('metadataEditor.fields.year')}</label>
<TextField
style={fieldStyle()}
type="number"
value={form.year}
onChange={(e) => updateField('year')(e.target.value)}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>{t('metadataEditor.fields.trackNumber')}</label>
<TextField
style={fieldStyle()}
type="number"
value={form.trackNumber}
onChange={(e) => updateField('trackNumber')(e.target.value)}
/>
</div>
</div>
<div>
<label style={labelStyle}>{t('metadataEditor.fields.genre')}</label>
<TextField
style={fieldStyle()}
value={form.genre}
onChange={(e) => updateField('genre')(e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button
variant="primary"
onClick={() => void handleSave()}
disabled={applyResult.isLoading}
>
{applyResult.isLoading ? <Spinner size="sm" /> : t('metadataEditor.save')}
</Button>
</div>
</div>
</Card>
<Card>
<div
style={{
padding: '1.25rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9375rem' }}>
{t('metadataEditor.autoEnrich.title')}
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('metadataEditor.autoEnrich.hint')}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<Button
variant="ghost"
size="sm"
onClick={() => void enrichTrack(trackId)}
disabled={enrichResult.isLoading}
>
{enrichResult.isLoading ? (
<Spinner size="sm" />
) : (
t('metadataEditor.autoEnrich.reEnrich')
)}
</Button>
<Button
variant="primary"
size="sm"
onClick={() => void findMatches(trackId)}
disabled={matchesResult.isFetching}
>
{matchesResult.isFetching ? (
<Spinner size="sm" />
) : (
t('metadataEditor.autoEnrich.findMatches')
)}
</Button>
</div>
</div>
{enrichResult.isSuccess && (
<Callout variant="info">{t('metadataEditor.autoEnrich.enqueued')}</Callout>
)}
{matchesResult.isError && (
<Callout variant="danger">{t('metadataEditor.autoEnrich.error')}</Callout>
)}
{matchesResult.isSuccess && matchesResult.data && (
matchesResult.data.length === 0 ? (
<Callout variant="warning">{t('metadataEditor.autoEnrich.noMatches')}</Callout>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{matchesResult.data.map((match) => (
<MatchRow
key={match.acoustid}
match={match}
onUse={() => setSelectedMatch(match)}
/>
))}
</div>
)
)}
{selectedMatch && (
<DiffView
current={form}
proposed={selectedMatch}
onApply={() => applyMatch(selectedMatch)}
onCancel={() => setSelectedMatch(null)}
/>
)}
</div>
</Card>
</div>
</ScrollArea>
</div>
);
}
function MatchRow({ match, onUse }: { match: MetadataMatch; onUse: () => void }) {
const { t } = useTranslation();
const pct = Math.round(match.score * 100);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-surface-1)',
}}
>
<Badge variant={pct >= 80 ? 'lime' : pct >= 50 ? 'outline' : 'neutral'}>
{pct}%
</Badge>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{match.title ?? t('metadataEditor.matches.unknownTitle')}
</div>
<div
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{[match.artist, match.album, match.year]
.filter((v) => v !== undefined && v !== null && v !== '')
.join(' · ')}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onUse}>
{t('metadataEditor.matches.use')}
</Button>
</div>
);
}
interface DiffRowDef {
key: 'title' | 'artistName' | 'albumTitle' | 'year';
label: string;
current: string;
proposed?: string;
}
function DiffView({
current,
proposed,
onApply,
onCancel,
}: {
current: FormState;
proposed: MetadataMatch;
onApply: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const rows: DiffRowDef[] = [
{
key: 'title',
label: t('metadataEditor.fields.title'),
current: current.title,
proposed: proposed.title,
},
{
key: 'artistName',
label: t('metadataEditor.fields.artist'),
current: current.artistName,
proposed: proposed.artist,
},
{
key: 'albumTitle',
label: t('metadataEditor.fields.album'),
current: current.albumTitle,
proposed: proposed.album,
},
{
key: 'year',
label: t('metadataEditor.fields.year'),
current: current.year,
proposed: proposed.year != null ? String(proposed.year) : undefined,
},
];
const changed = rows.filter(
(row) => row.proposed !== undefined && row.proposed !== row.current,
);
return (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '0.875rem 1rem',
display: 'flex',
flexDirection: 'column',
gap: '0.625rem',
background: 'var(--color-surface-1)',
}}
>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>
{t('metadataEditor.diff.title')}
</div>
{changed.length === 0 ? (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('metadataEditor.diff.noChanges')}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
{changed.map((row) => (
<div key={row.key} style={{ fontSize: '0.8125rem' }}>
<span style={{ color: 'var(--color-text-3)' }}>{row.label}: </span>
<span style={{ textDecoration: 'line-through', color: 'var(--color-text-3)' }}>
{row.current || '—'}
</span>
{' → '}
<span style={{ color: 'var(--color-accent)', fontWeight: 600 }}>
{row.proposed || '—'}
</span>
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button variant="ghost" size="sm" onClick={onCancel}>
{t('metadataEditor.diff.cancel')}
</Button>
<Button variant="primary" size="sm" onClick={onApply} disabled={changed.length === 0}>
{t('metadataEditor.diff.apply')}
</Button>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { EmptyState } from '../../components/common/EmptyState';
/** `*` — 404. Lives inside AppShell so the sidebar/player stay visible. */
export function NotFoundPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<EmptyState
title={t('notFound.title')}
description={t('notFound.description')}
/>
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Link to="/library" style={{ color: 'var(--color-accent)' }}>
{t('notFound.backToLibrary')}
</Link>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { useParams, useNavigate } from 'react-router';
import { ScrollArea, IconButton, Button } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { ScrollArea, IconButton, Button } from '@olly/modern-sk';
import {
useGetPlaylistQuery,
useGetPlaylistTracksQuery,
@@ -13,6 +14,7 @@ import { setQueue } from '../../store/slices/queue';
import { formatDuration } from '../../lib/format';
export function PlaylistDetailPage() {
const { t } = useTranslation();
const { playlistId } = useParams<{ playlistId: string }>();
const navigate = useNavigate();
const dispatch = useAppDispatch();
@@ -35,7 +37,7 @@ export function PlaylistDetailPage() {
if (playlistQuery.isError) {
return (
<ErrorState
message="Failed to load playlist"
message={t('playlist.error')}
onRetry={() => playlistQuery.refetch()}
/>
);
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
variant="ghost"
size="sm"
onClick={() => navigate(-1)}
aria-label="Back"
aria-label={t('common.back')}
>
</IconButton>
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
letterSpacing: '0.05em',
}}
>
Playlist
{t('playlist.type')}
</p>
<h1
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
@@ -119,7 +121,7 @@ export function PlaylistDetailPage() {
}}
>
{playlist &&
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
</p>
</div>
<Button
@@ -127,7 +129,7 @@ export function PlaylistDetailPage() {
onClick={handlePlayAll}
disabled={!tracks.length}
>
Play
{t('playlist.play')}
</Button>
</div>
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
{tracksQuery.isError && (
<ErrorState
message="Failed to load tracks"
message={t('playlist.tracksError')}
onRetry={() => tracksQuery.refetch()}
/>
)}
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
tracks.length === 0 && (
<EmptyState
icon="♫"
title="Empty playlist"
description="This playlist has no tracks yet."
title={t('playlist.empty.title')}
description={t('playlist.empty.description')}
/>
)}
{tracks.map((track, i) => (
+8
View File
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/playlists` — user's playlist list. Scaffold only. */
export function PlaylistsPage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.playlists')} />;
}
+11
View File
@@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/**
* `/queue` — A11 full-screen play queue (narrow viewports). On desktop the
* queue is the `QueuePanel` drawer in AppShell, not this route. Scaffold only.
*/
export function QueuePage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.queue')} />;
}
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function SearchDownloadPage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Search & Download">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.search')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+29 -5
View File
@@ -1,10 +1,34 @@
import { Window } from 'modern-sk';
import { Outlet } from 'react-router';
import { useTranslation } from 'react-i18next';
import { SubNav, type SubNavItem } from '../../components/common/SubNav';
/**
* `/settings` — A10 settings shell. Hosts a secondary nav + nested `<Outlet/>`
* for the profile/playback/scrobbling/instance panels. `/settings` itself
* redirects to `/settings/profile` (see routes).
*/
export function SettingsPage() {
const { t } = useTranslation();
const items: SubNavItem[] = [
{ to: '/settings/profile', label: t('settings.tabs.profile') },
{ to: '/settings/playback', label: t('settings.tabs.playback') },
{ to: '/settings/scrobbling', label: t('settings.tabs.scrobbling') },
{ to: '/settings/instance', label: t('settings.tabs.instance') },
];
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Settings">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
</Window>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<h1 className="page-title">{t('pages.settings')}</h1>
<SubNav items={items} />
<Outlet />
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Window, SegmentedControl, useTheme } from '@olly/modern-sk';
import { SUPPORTED_LANGUAGES, setLanguage } from '../../i18n';
/** Labelled settings row: caption on the left, control on the right. */
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<span
style={{
fontSize: '0.875rem',
color: 'var(--color-text-2)',
minWidth: '6rem',
}}
>
{label}
</span>
{children}
</div>
);
}
/** `/settings/profile` — profile + app language + theme (all wired). */
export function ProfileSettings() {
const { t, i18n } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<Window title={t('settings.tabs.profile')}>
<div
style={{
padding: '0.75rem 0',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
}}
>
<SettingRow label={t('settings.language')}>
<SegmentedControl
value={i18n.language}
onValueChange={setLanguage}
items={SUPPORTED_LANGUAGES.map((l) => ({
value: l.code,
label: l.label,
}))}
/>
</SettingRow>
<SettingRow label={t('settings.theme')}>
<SegmentedControl
value={theme}
onValueChange={(v) => setTheme(v === 'light' ? 'light' : 'dark')}
items={[
{ value: 'dark', label: t('settings.themeDark') },
{ value: 'light', label: t('settings.themeLight') },
]}
/>
</SettingRow>
</div>
</Window>
);
}
/** `/settings/playback` — default stream quality / playback behaviour. Scaffold. */
export function PlaybackSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.playback')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/settings/scrobbling` — last.fm / ListenBrainz linking. Scaffold. */
export function ScrobblingSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.scrobbling')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
/** `/settings/instance` — switch/forget instance. Scaffold. */
export function InstanceSettings() {
const { t } = useTranslation();
return (
<Window title={t('settings.tabs.instance')}>
<p style={{ color: 'var(--color-text-2)', margin: 0 }}>
{t('common.comingSoon')}
</p>
</Window>
);
}
@@ -0,0 +1,8 @@
import { useTranslation } from 'react-i18next';
import { Placeholder } from '../../components/common/Placeholder';
/** `/storage/maintenance` — A6 maintenance (dupes, broken files, cleanup). Scaffold only. */
export function StorageMaintenancePage() {
const { t } = useTranslation();
return <Placeholder title={t('pages.storageMaintenance')} />;
}
+6 -3
View File
@@ -1,9 +1,12 @@
import { Window } from 'modern-sk';
import { useTranslation } from 'react-i18next';
import { Window } from '@olly/modern-sk';
export function StoragePage() {
const { t } = useTranslation();
return (
<div style={{ padding: '1.5rem' }}>
<Window title="Storage">
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
<Window title={t('pages.storage')}>
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
</Window>
</div>
);
+379
View File
@@ -0,0 +1,379 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { Badge, Button, Callout, ScrollArea, Spinner } from '@olly/modern-sk';
import {
buildUploadFormData,
useUploadTrackMutation,
} from '../../api/endpoints/upload';
import { useGetTrackQuery } from '../../api/endpoints/library';
import { MetadataStatusBadge } from '../../components/track/MetadataStatusBadge';
/** Pure client-side state — this is a transient upload queue, never server data. */
type ItemStatus = 'queued' | 'uploading' | 'done' | 'duplicate' | 'error';
interface QueueItem {
id: string;
file: File;
status: ItemStatus;
error?: string;
trackId?: string;
}
/** The endpoint takes one file per request, so we cap concurrent requests. */
const MAX_CONCURRENCY = 3;
function extractError(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const e = err as { data?: { message?: string; detail?: string }; error?: string };
return e.data?.message ?? e.data?.detail ?? e.error ?? 'Upload failed';
}
return 'Upload failed';
}
/** `/upload` — A8 drag-and-drop upload of own files. */
export function UploadPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const [uploadTrack] = useUploadTrackMutation();
const [items, setItems] = useState<QueueItem[]>([]);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const idCounter = useRef(0);
const activeCount = useRef(0);
const pending = useRef<QueueItem[]>([]);
const patchItem = (id: string, patch: Partial<QueueItem>) =>
setItems((prev) => prev.map((it) => (it.id === id ? { ...it, ...patch } : it)));
// Ref-based concurrency pump: refs (not state) so it is safe to call from
// async callbacks without stale closures over the queue.
const pump = () => {
while (activeCount.current < MAX_CONCURRENCY && pending.current.length > 0) {
const item = pending.current.shift()!;
activeCount.current += 1;
patchItem(item.id, { status: 'uploading', error: undefined });
uploadTrack(buildUploadFormData(item.file))
.unwrap()
.then((res) => {
patchItem(item.id, {
status: res.already_exists ? 'duplicate' : 'done',
trackId: res.track_id,
});
})
.catch((err: unknown) => {
patchItem(item.id, { status: 'error', error: extractError(err) });
})
.finally(() => {
activeCount.current -= 1;
pump();
});
}
};
const addFiles = (files: File[]) => {
if (files.length === 0) return;
const newItems: QueueItem[] = files.map((file) => ({
id: `u${idCounter.current++}`,
file,
status: 'queued',
}));
setItems((prev) => [...prev, ...newItems]);
pending.current.push(...newItems);
pump();
};
const retry = (id: string) => {
let target: QueueItem | undefined;
setItems((prev) => {
target = prev.find((it) => it.id === id);
return prev.map((it) =>
it.id === id ? { ...it, status: 'queued', error: undefined } : it,
);
});
if (target) {
pending.current.push({ ...target, status: 'queued', error: undefined });
pump();
}
};
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files));
e.target.value = ''; // allow re-selecting the same file
};
const onDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files) addFiles(Array.from(e.dataTransfer.files));
};
const completed = items.filter(
(it) => it.status === 'done' || it.status === 'duplicate',
).length;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div
style={{
padding: '1.25rem 1.5rem',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
}}
>
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
{t('upload.title')}
</h2>
</div>
<ScrollArea style={{ flex: 1 }}>
<div
style={{
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1.25rem',
}}
>
<div
onClick={() => inputRef.current?.click()}
onDragEnter={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${
dragging ? 'var(--color-accent)' : 'var(--color-border)'
}`,
borderRadius: 10,
padding: '2.5rem 1.5rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
background: dragging ? 'var(--color-surface-2)' : 'transparent',
transition: 'background 120ms, border-color 120ms',
}}
>
<div style={{ fontSize: '2rem' }}></div>
<div style={{ fontWeight: 600 }}>{t('upload.dropzone.title')}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-3)' }}>
{t('upload.dropzone.hint')}
</div>
<Button variant="primary" size="sm" type="button">
{t('upload.dropzone.button')}
</Button>
<input
ref={inputRef}
type="file"
multiple
accept="audio/*"
onChange={onInputChange}
style={{ display: 'none' }}
/>
</div>
<Callout variant="info">{t('upload.metadataPending')}</Callout>
{items.length > 0 && (
<div
style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>
{t('upload.queueTitle', {
completed,
total: items.length,
})}
</span>
{completed > 0 && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={() =>
setItems((prev) =>
prev.filter(
(it) =>
it.status !== 'done' && it.status !== 'duplicate',
),
)
}
>
{t('upload.clearCompleted')}
</Button>
)}
</div>
{items.map((item) => (
<UploadRow
key={item.id}
item={item}
onRetry={() => retry(item.id)}
onEditMetadata={() =>
void navigate(`/tracks/${item.trackId}/metadata`)
}
/>
))}
</div>
)}
</div>
</ScrollArea>
</div>
);
}
function UploadRow({
item,
onRetry,
onEditMetadata,
}: {
item: QueueItem;
onRetry: () => void;
onEditMetadata: () => void;
}) {
const { t } = useTranslation();
const done = item.status === 'done' || item.status === 'duplicate';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
border: '1px solid var(--color-border)',
borderRadius: 8,
background: 'var(--color-surface-1)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '0.875rem',
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={item.file.name}
>
{item.file.name}
</div>
{item.status === 'error' && item.error && (
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
{item.error}
</div>
)}
{done && item.trackId && <EnrichmentStatus trackId={item.trackId} />}
</div>
<StatusBadge status={item.status} />
{item.status === 'error' && (
<Button variant="ghost" size="sm" type="button" onClick={onRetry}>
{t('upload.retry')}
</Button>
)}
{done && item.trackId && (
<Button
variant="ghost"
size="sm"
type="button"
onClick={onEditMetadata}
>
{t('upload.editMetadata')}
</Button>
)}
</div>
);
}
/**
* Polls a just-uploaded track until enrichment settles, then shows the outcome.
* Metadata enrichment runs asynchronously in a worker after the upload response
* returns, so without polling the row would never reflect the resolved title/
* artist or a failure reason. Polling stops (interval → 0) once the status
* leaves `pending`.
*/
function EnrichmentStatus({ trackId }: { trackId: string }) {
const { t } = useTranslation();
const [pollMs, setPollMs] = useState(2500);
const { data } = useGetTrackQuery(trackId, { pollingInterval: pollMs });
useEffect(() => {
if (data && data.metadataStatus !== 'pending') setPollMs(0);
}, [data]);
const status = data?.metadataStatus ?? 'pending';
const resolved =
data && data.metadataStatus === 'enriched'
? `${data.artistName} · ${data.title}`
: t(`metadata.statusHint.${status}`);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginTop: '0.25rem',
}}
>
<MetadataStatusBadge
status={status}
error={data?.metadataError}
hideWhenEnriched={false}
/>
<span
style={{
fontSize: '0.75rem',
color: 'var(--color-text-3)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{status === 'failed' && data?.metadataError
? data.metadataError
: resolved}
</span>
</div>
);
}
function StatusBadge({ status }: { status: ItemStatus }) {
const { t } = useTranslation();
if (status === 'uploading') {
return (
<Badge variant="neutral">
<Spinner size="sm" /> {t('upload.status.uploading')}
</Badge>
);
}
const cfg: Record<
Exclude<ItemStatus, 'uploading'>,
{ variant: 'lime' | 'ember' | 'neutral' | 'outline'; key: string }
> = {
queued: { variant: 'neutral', key: 'upload.status.queued' },
done: { variant: 'lime', key: 'upload.status.done' },
duplicate: { variant: 'outline', key: 'upload.status.duplicate' },
error: { variant: 'ember', key: 'upload.status.error' },
};
const { variant, key } = cfg[status];
return (
<Badge variant={variant} dot>
{t(key)}
</Badge>
);
}
+5 -3
View File
@@ -3,7 +3,9 @@ import { getApiBaseUrl } from '../config/runtime-config';
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export function useConnectionStatus() {
/** Pings `${baseUrl}/health` (defaults to the active instance's base URL). */
export function useConnectionStatus(baseUrl?: string) {
const url = baseUrl ?? getApiBaseUrl();
const [status, setStatus] = useState<ConnectionStatus>('connecting');
useEffect(() => {
@@ -13,7 +15,7 @@ export function useConnectionStatus() {
if (cancelled) return;
setStatus('connecting');
try {
const res = await fetch(`${getApiBaseUrl()}/health`, {
const res = await fetch(`${url}/health`, {
signal: AbortSignal.timeout(5000),
});
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
@@ -30,7 +32,7 @@ export function useConnectionStatus() {
cancelled = true;
clearInterval(interval);
};
}, []);
}, [url]);
return status;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useAppSelector } from './useAppDispatch';
type Permission =
export type Permission =
| 'download'
| 'upload'
| 'admin'
+37
View File
@@ -0,0 +1,37 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useGetTrackQuery } from '../api/endpoints/library';
import type { QueueEntry } from '../store/slices/queue';
export interface ResolvedQueueEntry {
trackId: string;
title: string;
artistName: string;
albumTitle: string;
durationMs: number;
hasCover: boolean;
}
/**
* Merge a queue entry's play-time snapshot with the live `Track` cache.
*
* The queue slice stores denormalized display fields (title/artist/…) captured
* when a track was queued, so they go stale after metadata enrichment updates
* the track. This reads through to the RTKQ `Track` cache — invalidated by the
* same tags that refresh the library — and prefers its fresh values, falling
* back to the snapshot for instant render and offline. Returns undefined when
* there is no current entry.
*/
export function useResolvedQueueEntry(
entry: QueueEntry | undefined,
): ResolvedQueueEntry | undefined {
const { data } = useGetTrackQuery(entry?.trackId ?? skipToken);
if (!entry) return undefined;
return {
trackId: entry.trackId,
title: data?.title ?? entry.title,
artistName: data?.artistName ?? entry.artistName,
albumTitle: data?.albumTitle ?? entry.albumTitle,
durationMs: data?.durationMs ?? entry.durationMs,
hasCover: data?.hasCover ?? false,
};
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from 'react';
import { useAppSelector } from './useAppDispatch';
import { isStreamCached } from '../lib/sw';
import { getStreamUrl } from '../api/endpoints/streaming';
/**
* Whether the given track is available from the offline audio cache (Tier 3).
* Drives the player-bar source indicator (local vs streaming). Returns false
* until the service worker is controlling and confirms a hit.
*/
export function useStreamCached(trackId: string | undefined): boolean {
const token = useAppSelector((s) => s.auth.accessToken);
const [cached, setCached] = useState(false);
useEffect(() => {
if (!trackId || !token) {
setCached(false);
return;
}
let active = true;
void isStreamCached(getStreamUrl(trackId, token)).then((hit) => {
if (active) setCached(hit);
});
return () => {
active = false;
};
}, [trackId, token]);
return cached;
}
+37
View File
@@ -0,0 +1,37 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en';
import ru from './locales/ru';
const STORAGE_KEY = 'mcma_lang';
function detectLanguage(): string {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return stored;
const browser = navigator.language.split('-')[0];
return browser === 'ru' ? 'ru' : 'en';
}
export function setLanguage(lang: string): void {
localStorage.setItem(STORAGE_KEY, lang);
void i18n.changeLanguage(lang);
}
export const SUPPORTED_LANGUAGES = [
{ code: 'en', label: 'English' },
{ code: 'ru', label: 'Русский' },
] as const;
void i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
ru: { translation: ru },
},
lng: detectLanguage(),
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
});
export default i18n;
+326
View File
@@ -0,0 +1,326 @@
const en = {
nav: {
library: 'Library',
search: 'Search & download',
downloads: 'Downloads',
upload: 'Upload',
storage: 'Storage',
playlists: 'Playlists',
newPlaylist: 'New playlist',
admin: 'Admin',
settings: 'Settings',
administration: 'Administration',
},
conn: {
connected: 'Connected',
connecting: 'Connecting…',
disconnected: 'Offline',
error: 'Unreachable',
manage: 'Connection — manage instances',
cached: 'Showing last-seen data',
},
user: {
online: 'online',
offline: 'offline',
signOut: 'Sign out',
},
connect: {
domains: {
title: 'Saved instances',
addPlaceholder: 'https://your-server.example.com',
addButton: 'Add instance',
selected: 'Selected',
use: 'Use',
forgetTitle: 'Remove this instance',
},
removeDialog: {
title: 'Remove cached data?',
description:
'This removes "{{name}}" from your saved instances and clears its cached data on this device.',
cancel: 'Cancel',
logout: 'Just log out',
removeAndLogout: 'Remove data & log out',
},
login: {
title: 'Log in to {{name}}',
registerTitle: 'Sign up for {{name}}',
username: 'Username',
password: 'Password',
passwordHint: 'At least 8 characters.',
submit: 'Log in',
submitting: 'Logging in…',
registerSubmit: 'Sign up',
registering: 'Signing up…',
noAccount: "Don't have an account?",
registerLink: 'Sign up',
haveAccount: 'Already have an account?',
signInLink: 'Log in',
},
errors: {
unreachable:
"Can't reach this server. Check the URL and that it's online.",
badCredentials: 'Incorrect username or password.',
generic: 'Sign-in failed. Please try again.',
usernameTaken: 'That username is already taken.',
passwordTooShort: 'Password must be at least 8 characters.',
registrationDisabled: 'Registration is disabled on this server.',
registerFailed: 'Could not create the account. Please try again.',
},
},
library: {
title: 'Library',
searchPlaceholder: 'Search library…',
tabs: {
tracks: 'Tracks',
albums: 'Albums',
artists: 'Artists',
},
playAll: '▶ Play all ({{count}})',
empty: {
tracks: {
title: 'No tracks',
description: 'Your library is empty. Start by downloading some music.',
},
albums: {
title: 'No albums',
description: 'No albums in library.',
},
artists: {
title: 'No artists',
description: 'No artists in library.',
},
},
albumCard: {
tracks: '{{count}} tracks',
tracksDuration: '{{count}} tracks · {{duration}}',
},
artistRow: {
meta: '{{albumCount}} albums · {{trackCount}} tracks',
},
},
album: {
type: 'Album',
play: '▶ Play',
error: 'Failed to load album',
tracksError: 'Failed to load tracks',
empty: {
title: 'No tracks',
description: 'This album has no tracks.',
},
},
playlist: {
type: 'Playlist',
play: '▶ Play',
error: 'Failed to load playlist',
tracksError: 'Failed to load tracks',
empty: {
title: 'Empty playlist',
description: 'This playlist has no tracks yet.',
},
},
player: {
nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous',
next: 'Next',
pause: 'Pause',
play: 'Play',
repeat: 'Repeat: {{mode}}',
streaming: 'Streaming · 320 kbps',
local: 'Local · FLAC',
queue: 'Play queue',
mute: 'Mute',
unmute: 'Unmute',
},
queue: {
title: 'Play queue',
clear: 'Clear queue',
close: 'Close',
from: 'From {{source}}',
radio: 'Radio · {{source}}',
nowPlaying: 'Now playing',
nextUp: 'Next up',
nothingNext: 'Nothing queued next',
empty: 'Queue is empty',
radioActive: 'Radio active',
mixing: '∞ mixing',
familiar: 'Familiar',
new: 'New',
loadingMore: 'Loading more from radio…',
doubleClickPlay: 'Double-click to play',
removeFromQueue: 'Remove from queue',
},
track: {
menu: {
options: 'Track options',
playNow: 'Play now',
playNext: 'Play next',
addToQueue: 'Add to queue',
info: 'Track info',
addToPlaylist: 'Add to playlist…',
editMetadata: 'Edit metadata',
download: 'Download',
delete: 'Delete',
},
},
trackInfo: {
title: 'Track info',
open: 'View track info',
close: 'Close',
notFound: 'Track not found',
play: 'Play',
addToQueue: 'Queue',
editMetadata: 'Edit metadata',
liked: 'Liked',
trackOf: 'No. {{n}} of {{total}}',
kbps: '{{n}} kbps',
sections: {
status: 'Status',
general: 'General',
file: 'File',
identifiers: 'Identifiers',
},
fields: {
artist: 'Artist',
album: 'Album',
trackNumber: 'Track',
disc: 'Disc',
year: 'Year',
genre: 'Genre',
duration: 'Duration',
format: 'Format',
bitrate: 'Bitrate',
size: 'Size',
source: 'Source',
added: 'Added',
enriched: 'Enriched',
trackId: 'Track ID',
albumId: 'Album ID',
artistId: 'Artist ID',
},
},
common: {
error: 'Something went wrong',
retry: 'Retry',
comingSoon: 'Coming soon',
back: 'Back',
},
pages: {
admin: 'Admin',
settings: 'Settings',
downloads: 'Downloads',
search: 'Search & Download',
storage: 'Storage',
login: 'Sign in',
artist: 'Artist',
playlists: 'Playlists',
upload: 'Upload files',
metadata: 'Edit metadata',
metadataBatch: 'Edit metadata (batch)',
storageMaintenance: 'Storage maintenance',
queue: 'Play queue',
},
settings: {
language: 'Language',
theme: 'Theme',
themeDark: 'Dark',
themeLight: 'Light',
tabs: {
profile: 'Profile',
playback: 'Playback',
scrobbling: 'Scrobbling',
instance: 'Instance',
},
},
admin: {
userDetail: 'User',
tabs: {
users: 'Users',
sources: 'Sources',
instance: 'Instance',
},
},
notFound: {
title: 'Page not found',
description: "This screen doesn't exist yet.",
backToLibrary: 'Back to library',
},
upload: {
title: 'Upload files',
dropzone: {
title: 'Drag & drop audio files here',
hint: 'or click to choose files — one or many at a time',
button: 'Choose files',
},
queueTitle: 'Uploads ({{completed}}/{{total}})',
clearCompleted: 'Clear completed',
retry: 'Retry',
editMetadata: 'Edit metadata',
metadataPending:
'Uploaded tracks land as “Unknown Artist” with metadata pending — enrich them afterwards.',
unknownArtist: 'Unknown Artist · metadata pending',
status: {
queued: 'Queued',
uploading: 'Uploading',
done: 'Uploaded',
duplicate: 'Already in library',
error: 'Failed',
},
},
metadata: {
status: {
pending: 'Enriching…',
enriched: 'Enriched',
failed: 'No match',
manual: 'Manual',
},
statusHint: {
pending: 'Identifying metadata…',
enriched: 'Metadata identified',
failed: 'Metadata could not be identified',
manual: 'Edited manually — not auto-updated',
},
},
metadataEditor: {
error: 'Failed to load track',
saved: 'Metadata saved.',
saveError: 'Failed to save metadata.',
save: 'Save',
fields: {
title: 'Title',
artist: 'Artist',
album: 'Album',
year: 'Year',
genre: 'Genre',
trackNumber: 'Track number',
},
autoEnrich: {
title: 'AcoustID lookup',
hint: 'Identify this track by audio fingerprint.',
findMatches: 'Find matches',
reEnrich: 'Re-run enrichment',
enqueued: 'Enrichment queued — refresh in a moment.',
error: 'Could not look up matches.',
noMatches: 'No matches found.',
},
matches: {
use: 'Use',
unknownTitle: 'Unknown title',
},
diff: {
title: 'Apply this match?',
noChanges: 'No changes from current values.',
cancel: 'Cancel',
apply: 'Apply',
},
},
} as const;
export default en;
type DeepString<T> = {
[K in keyof T]: T[K] extends Record<string, unknown>
? DeepString<T[K]>
: string;
};
export type Translations = DeepString<typeof en>;
+321
View File
@@ -0,0 +1,321 @@
import type { Translations } from './en';
const ru: Translations = {
nav: {
library: 'Библиотека',
search: 'Поиск и загрузка',
downloads: 'Загрузки',
upload: 'Загрузить',
storage: 'Хранилище',
playlists: 'Плейлисты',
newPlaylist: 'Новый плейлист',
admin: 'Администрирование',
settings: 'Настройки',
administration: 'Администрирование',
},
conn: {
connected: 'Подключено',
connecting: 'Подключение…',
disconnected: 'Нет связи',
error: 'Недоступно',
manage: 'Соединение — управление экземплярами',
cached: 'Показаны последние данные',
},
user: {
online: 'онлайн',
offline: 'офлайн',
signOut: 'Выйти',
},
connect: {
domains: {
title: 'Сохранённые серверы',
addPlaceholder: 'https://your-server.example.com',
addButton: 'Добавить сервер',
selected: 'Выбран',
use: 'Выбрать',
forgetTitle: 'Удалить этот сервер',
},
removeDialog: {
title: 'Удалить локальные данные?',
description:
'Сервер «{{name}}» будет удалён из сохранённых, а его данные на этом устройстве — очищены.',
cancel: 'Отмена',
logout: 'Просто выйти',
removeAndLogout: 'Удалить данные и выйти',
},
login: {
title: 'Вход в {{name}}',
registerTitle: 'Регистрация на {{name}}',
username: 'Имя пользователя',
password: 'Пароль',
passwordHint: 'Не менее 8 символов.',
submit: 'Войти',
submitting: 'Вход…',
registerSubmit: 'Зарегистрироваться',
registering: 'Регистрация…',
noAccount: 'Нет аккаунта?',
registerLink: 'Зарегистрироваться',
haveAccount: 'Уже есть аккаунт?',
signInLink: 'Войти',
},
errors: {
unreachable:
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
badCredentials: 'Неверное имя пользователя или пароль.',
generic: 'Не удалось войти. Попробуйте ещё раз.',
usernameTaken: 'Это имя пользователя уже занято.',
passwordTooShort: 'Пароль должен содержать не менее 8 символов.',
registrationDisabled: 'Регистрация на этом сервере отключена.',
registerFailed: 'Не удалось создать аккаунт. Попробуйте ещё раз.',
},
},
library: {
title: 'Библиотека',
searchPlaceholder: 'Поиск в библиотеке…',
tabs: {
tracks: 'Треки',
albums: 'Альбомы',
artists: 'Исполнители',
},
playAll: '▶ Воспроизвести все ({{count}})',
empty: {
tracks: {
title: 'Нет треков',
description: 'Библиотека пуста. Начните с загрузки музыки.',
},
albums: {
title: 'Нет альбомов',
description: 'В библиотеке нет альбомов.',
},
artists: {
title: 'Нет исполнителей',
description: 'В библиотеке нет исполнителей.',
},
},
albumCard: {
tracks: '{{count}} треков',
tracksDuration: '{{count}} треков · {{duration}}',
},
artistRow: {
meta: '{{albumCount}} альб. · {{trackCount}} треков',
},
},
album: {
type: 'Альбом',
play: '▶ Слушать',
error: 'Не удалось загрузить альбом',
tracksError: 'Не удалось загрузить треки',
empty: {
title: 'Нет треков',
description: 'В этом альбоме нет треков.',
},
},
playlist: {
type: 'Плейлист',
play: '▶ Слушать',
error: 'Не удалось загрузить плейлист',
tracksError: 'Не удалось загрузить треки',
empty: {
title: 'Плейлист пуст',
description: 'В этом плейлисте пока нет треков.',
},
},
player: {
nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад',
next: 'Вперёд',
pause: 'Пауза',
play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}',
streaming: 'Стриминг · 320 kbps',
local: 'Локально · FLAC',
queue: 'Очередь',
mute: 'Выключить звук',
unmute: 'Включить звук',
},
queue: {
title: 'Очередь воспроизведения',
clear: 'Очистить очередь',
close: 'Закрыть',
from: 'Из: {{source}}',
radio: 'Радио · {{source}}',
nowPlaying: 'Сейчас играет',
nextUp: 'Далее',
nothingNext: 'Очередь пуста',
empty: 'Очередь пуста',
radioActive: 'Радио активно',
mixing: '∞ микс',
familiar: 'Знакомое',
new: 'Новое',
loadingMore: 'Загрузка радио…',
doubleClickPlay: 'Двойной клик для воспроизведения',
removeFromQueue: 'Убрать из очереди',
},
track: {
menu: {
options: 'Действия с треком',
playNow: 'Играть сейчас',
playNext: 'Следующим',
addToQueue: 'Добавить в очередь',
info: 'Информация о треке',
addToPlaylist: 'Добавить в плейлист…',
editMetadata: 'Редактировать метаданные',
download: 'Скачать',
delete: 'Удалить',
},
},
trackInfo: {
title: 'О треке',
open: 'Информация о треке',
close: 'Закрыть',
notFound: 'Трек не найден',
play: 'Играть',
addToQueue: 'В очередь',
editMetadata: 'Метаданные',
liked: 'В избранном',
trackOf: '№ {{n}} из {{total}}',
kbps: '{{n}} кбит/с',
sections: {
status: 'Статус',
general: 'Основное',
file: 'Файл',
identifiers: 'Идентификаторы',
},
fields: {
artist: 'Исполнитель',
album: 'Альбом',
trackNumber: 'Трек',
disc: 'Диск',
year: 'Год',
genre: 'Жанр',
duration: 'Длительность',
format: 'Формат',
bitrate: 'Битрейт',
size: 'Размер',
source: 'Источник',
added: 'Добавлен',
enriched: 'Обогащён',
trackId: 'ID трека',
albumId: 'ID альбома',
artistId: 'ID исполнителя',
},
},
common: {
error: 'Что-то пошло не так',
retry: 'Повторить',
comingSoon: 'Скоро',
back: 'Назад',
},
pages: {
admin: 'Администрирование',
settings: 'Настройки',
downloads: 'Загрузки',
search: 'Поиск и загрузка',
storage: 'Хранилище',
login: 'Вход',
artist: 'Артист',
playlists: 'Плейлисты',
upload: 'Загрузка файлов',
metadata: 'Редактирование метаданных',
metadataBatch: 'Редактирование метаданных (массово)',
storageMaintenance: 'Обслуживание хранилища',
queue: 'Очередь воспроизведения',
},
settings: {
language: 'Язык',
theme: 'Тема',
themeDark: 'Тёмная',
themeLight: 'Светлая',
tabs: {
profile: 'Профиль',
playback: 'Воспроизведение',
scrobbling: 'Скробблинг',
instance: 'Сервер',
},
},
admin: {
userDetail: 'Пользователь',
tabs: {
users: 'Пользователи',
sources: 'Источники',
instance: 'Сервер',
},
},
notFound: {
title: 'Страница не найдена',
description: 'Этого экрана пока нет.',
backToLibrary: 'Вернуться в библиотеку',
},
upload: {
title: 'Загрузка файлов',
dropzone: {
title: 'Перетащите аудиофайлы сюда',
hint: 'или нажмите, чтобы выбрать файлы — по одному или сразу несколько',
button: 'Выбрать файлы',
},
queueTitle: 'Загрузки ({{completed}}/{{total}})',
clearCompleted: 'Убрать завершённые',
retry: 'Повторить',
editMetadata: 'Изменить метаданные',
metadataPending:
'Загруженные треки появляются как «Unknown Artist» с метаданными в ожидании — дозаполните их позже.',
unknownArtist: 'Unknown Artist · метаданные в ожидании',
status: {
queued: 'В очереди',
uploading: 'Загрузка',
done: 'Загружено',
duplicate: 'Уже в библиотеке',
error: 'Ошибка',
},
},
metadata: {
status: {
pending: 'Обработка…',
enriched: 'Готово',
failed: 'Нет совпадения',
manual: 'Вручную',
},
statusHint: {
pending: 'Определяем метаданные…',
enriched: 'Метаданные определены',
failed: 'Не удалось определить метаданные',
manual: 'Изменено вручную — не обновляется автоматически',
},
},
metadataEditor: {
error: 'Не удалось загрузить трек',
saved: 'Метаданные сохранены.',
saveError: 'Не удалось сохранить метаданные.',
save: 'Сохранить',
fields: {
title: 'Название',
artist: 'Исполнитель',
album: 'Альбом',
year: 'Год',
genre: 'Жанр',
trackNumber: 'Номер трека',
},
autoEnrich: {
title: 'Поиск по AcoustID',
hint: 'Определить трек по аудио-отпечатку.',
findMatches: 'Найти совпадения',
reEnrich: 'Повторить обогащение',
enqueued: 'Обогащение запущено — обновите через момент.',
error: 'Не удалось найти совпадения.',
noMatches: 'Совпадений не найдено.',
},
matches: {
use: 'Использовать',
unknownTitle: 'Неизвестное название',
},
diff: {
title: 'Применить это совпадение?',
noChanges: 'Нет изменений относительно текущих значений.',
cancel: 'Отмена',
apply: 'Применить',
},
},
};
export default ru;
+9 -3
View File
@@ -1,14 +1,16 @@
import 'modern-sk/styles.css';
import 'modern-sk/fonts.css';
import '@olly/modern-sk/styles.css';
import '@olly/modern-sk/fonts.css';
import './styles/global.css';
import './styles/shell.css';
import './i18n';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';
import { ThemeProvider, TooltipProvider } from 'modern-sk';
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
import { store } from './store';
import { AppRoutes } from './routes';
import { registerServiceWorker } from './lib/sw';
// Import all endpoint injections to ensure they are registered
import './api/endpoints/auth';
@@ -20,6 +22,10 @@ import './api/endpoints/storage';
import './api/endpoints/admin';
import './api/endpoints/upload';
// Tier 3 offline: register the audio-caching service worker (no-op if the
// browser/origin doesn't support it).
registerServiceWorker();
const rootEl = document.getElementById('root');
if (rootEl) {
// grained black-ish background + base text color from modern-sk
+10
View File
@@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function formatDateTime(iso: string | undefined): string | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d);
}
export function formatCount(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
+80
View File
@@ -0,0 +1,80 @@
/*
* Service-worker client: registration + a typed bridge to the audio offline
* cache (Tier 3). The SW itself lives in `public/sw.js`; this is the app side.
*
* Messaging uses a MessageChannel — we hand the SW a port and await its reply —
* so each call resolves with that request's result rather than a global event.
*/
export interface AudioCacheStats {
count: number;
bytes: number;
maxBytes: number;
}
/** Register the service worker. No-op when unsupported (e.g. plain http host). */
export function registerServiceWorker(): void {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return;
}
window.addEventListener('load', () => {
// Module worker: sw.js uses ES imports (see public/sw.js + sw-core.js).
navigator.serviceWorker.register('/sw.js', { type: 'module' }).catch(() => {
/* SW unavailable (insecure origin, blocked, …) — app still works online */
});
});
}
function controller(): ServiceWorker | null {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
return null;
}
return navigator.serviceWorker.controller;
}
/** Round-trip a message to the SW; rejects if no controlling SW is present. */
function send<T>(message: Record<string, unknown>): Promise<T> {
const sw = controller();
if (!sw) return Promise.reject(new Error('no-service-worker'));
return new Promise<T>((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => resolve(event.data as T);
try {
sw.postMessage(message, [channel.port2]);
} catch (err) {
reject(err as Error);
}
});
}
/** Total size + count of cached audio, or null when the SW isn't controlling. */
export async function getAudioCacheStats(): Promise<AudioCacheStats | null> {
try {
return await send<AudioCacheStats>({ type: 'STATS' });
} catch {
return null;
}
}
/** Whether a given stream URL is already cached for offline playback. */
export async function isStreamCached(streamUrl: string): Promise<boolean> {
try {
const { cached } = await send<{ cached: boolean }>({
type: 'HAS',
url: streamUrl,
});
return cached;
} catch {
return false;
}
}
/** Drop the entire audio cache. Resolves false if the SW isn't controlling. */
export async function clearAudioCache(): Promise<boolean> {
try {
const { ok } = await send<{ ok: boolean }>({ type: 'CLEAR' });
return ok;
} catch {
return false;
}
}
+13 -1
View File
@@ -1,13 +1,21 @@
import { Navigate } from 'react-router';
import { useAppSelector } from '../hooks/useAppDispatch';
import { usePermissions, type Permission } from '../hooks/usePermissions';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
/** Gate the route on a granular permission (e.g. download, upload). */
requirePermission?: Permission;
}
export function ProtectedRoute({ children, requireAdmin = false }: Props) {
export function ProtectedRoute({
children,
requireAdmin = false,
requirePermission,
}: Props) {
const auth = useAppSelector((s) => s.auth);
const { hasPermission } = usePermissions();
if (!auth.accessToken || !auth.user) {
return <Navigate to="/connect" replace />;
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
return <Navigate to="/library" replace />;
}
if (requirePermission && !hasPermission(requirePermission)) {
return <Navigate to="/library" replace />;
}
return <>{children}</>;
}
+109 -51
View File
@@ -1,14 +1,38 @@
import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router';
import { AppShell } from '../components/layout/AppShell';
import { ProtectedRoute } from './ProtectedRoute';
// Public (outside the shell)
import { ConnectPage } from '../features/connect/ConnectPage';
import { HomePage } from '../features/home/HomePage';
import { LoginPage } from '../features/auth/LoginPage';
// Core screens (eager — first paint inside the shell)
import { LibraryPage } from '../features/library/LibraryPage';
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
import { ArtistDetailPage } from '../features/artist-detail/ArtistDetailPage';
import { PlaylistsPage } from '../features/playlists/PlaylistsPage';
import { PlaylistDetailPage } from '../features/playlist-detail/PlaylistDetailPage';
import { lazy, Suspense } from 'react';
import { LoadingSkeleton } from '../components/common/LoadingSkeleton';
// Settings / Admin layouts + panels (small, eager)
import { SettingsPage } from '../features/settings/SettingsPage';
import {
ProfileSettings,
PlaybackSettings,
ScrobblingSettings,
InstanceSettings,
} from '../features/settings/panels';
import { AdminPage } from '../features/admin/AdminPage';
import {
AdminUsers,
AdminUserDetail,
AdminSources,
AdminInstance,
} from '../features/admin/panels';
import { NotFoundPage } from '../features/not-found/NotFoundPage';
// Secondary screens — lazily loaded (Suspense boundary lives in AppShell)
const SearchDownloadPage = lazy(() =>
import('../features/search-download/SearchDownloadPage').then((m) => ({
default: m.SearchDownloadPage,
@@ -19,30 +43,36 @@ const DownloadsManagerPage = lazy(() =>
default: m.DownloadsManagerPage,
})),
);
const UploadPage = lazy(() =>
import('../features/upload/UploadPage').then((m) => ({ default: m.UploadPage })),
);
const MetadataEditorPage = lazy(() =>
import('../features/metadata-editor/MetadataEditorPage').then((m) => ({
default: m.MetadataEditorPage,
})),
);
const StoragePage = lazy(() =>
import('../features/storage/StoragePage').then((m) => ({
default: m.StoragePage,
})),
);
const AdminPage = lazy(() =>
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
);
const SettingsPage = lazy(() =>
import('../features/settings/SettingsPage').then((m) => ({
default: m.SettingsPage,
const StorageMaintenancePage = lazy(() =>
import('../features/storage/StorageMaintenancePage').then((m) => ({
default: m.StorageMaintenancePage,
})),
);
const Fallback = () => (
<div style={{ padding: '2rem' }}>
<LoadingSkeleton />
</div>
const QueuePage = lazy(() =>
import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
);
export function AppRoutes() {
return (
<Routes>
{/* Public */}
<Route path="/connect" element={<ConnectPage />} />
<Route path="/login" element={<LoginPage />} />
{/* Authenticated shell */}
<Route
element={
<ProtectedRoute>
@@ -50,57 +80,85 @@ export function AppRoutes() {
</ProtectedRoute>
}
>
<Route index element={<HomePage />} />
<Route index element={<Navigate to="/library" replace />} />
{/* Library */}
<Route path="/library" element={<LibraryPage />} />
<Route path="/library/albums/:albumId" element={<AlbumDetailPage />} />
<Route path="/albums/:albumId" element={<AlbumDetailPage />} />
<Route path="/artists/:artistId" element={<ArtistDetailPage />} />
{/* Playlists */}
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:playlistId" element={<PlaylistDetailPage />} />
{/* Discover & downloads (permission-gated) */}
<Route
path="/library/playlists/:playlistId"
element={<PlaylistDetailPage />}
/>
<Route
path="/search"
path="/discover"
element={
<Suspense fallback={<Fallback />}>
<ProtectedRoute requirePermission="download">
<SearchDownloadPage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/downloads"
element={
<Suspense fallback={<Fallback />}>
<ProtectedRoute requirePermission="download">
<DownloadsManagerPage />
</Suspense>
}
/>
<Route
path="/storage"
element={
<Suspense fallback={<Fallback />}>
<StoragePage />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<Fallback />}>
<SettingsPage />
</Suspense>
}
/>
<Route
path="/admin/*"
element={
<ProtectedRoute requireAdmin>
<Suspense fallback={<Fallback />}>
<AdminPage />
</Suspense>
</ProtectedRoute>
}
/>
{/* Upload & metadata */}
<Route
path="/upload"
element={
<ProtectedRoute requirePermission="upload">
<UploadPage />
</ProtectedRoute>
}
/>
<Route
path="/tracks/:trackId/metadata"
element={<MetadataEditorPage />}
/>
<Route path="/metadata/batch" element={<MetadataEditorPage batch />} />
{/* Storage */}
<Route path="/storage" element={<StoragePage />} />
<Route path="/storage/maintenance" element={<StorageMaintenancePage />} />
{/* Queue (narrow viewports) */}
<Route path="/queue" element={<QueuePage />} />
{/* Settings */}
<Route path="/settings" element={<SettingsPage />}>
<Route index element={<Navigate to="/settings/profile" replace />} />
<Route path="profile" element={<ProfileSettings />} />
<Route path="playback" element={<PlaybackSettings />} />
<Route path="scrobbling" element={<ScrobblingSettings />} />
<Route path="instance" element={<InstanceSettings />} />
</Route>
{/* Admin (admin-gated) */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<AdminPage />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/admin/users" replace />} />
<Route path="users" element={<AdminUsers />} />
<Route path="users/:userId" element={<AdminUserDetail />} />
<Route path="sources" element={<AdminSources />} />
<Route path="instance" element={<AdminInstance />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<Navigate to="/library" replace />} />
</Routes>
);
}
+16
View File
@@ -4,6 +4,8 @@ import authReducer from './slices/auth';
import playerReducer from './slices/player';
import queueReducer from './slices/queue';
import uiReducer from './slices/ui';
import { loadPlayerState, loadQueueState, startPersistence } from './persist';
import { rehydrateApiCache, startApiPersistence } from './rtkqPersist';
export const store = configureStore({
reducer: {
@@ -13,9 +15,23 @@ export const store = configureStore({
queue: queueReducer,
ui: uiReducer,
},
// Tier 1 offline: rehydrate queue/player from the active backend's namespace
// so a reload (even with no backend) restores exactly where the user left off.
preloadedState: {
queue: loadQueueState(),
player: loadPlayerState(),
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
});
// Flush queue/player changes back to localStorage (throttled).
startPersistence(store);
// Tier 2 offline: replay the last-seen RTKQ cache, then keep snapshotting it.
// Rehydrate first so cached server data is present before any component mounts.
rehydrateApiCache(store.dispatch);
startApiPersistence(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
+123
View File
@@ -0,0 +1,123 @@
/*
* Tier 1 offline support: persist client state (queue + player) to the active
* backend's localStorage namespace, mirroring the auth slice. This is what lets
* the UI come back exactly as the user left it after a reload with no backend
* reachable — no server data is duplicated (the queue stores track IDs + minimal
* display fields only; full track records still live in the RTKQ cache).
*
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/
import { instanceStorage } from '../config/instances';
import {
queueInitialState,
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import type { RootState } from './index';
const QUEUE_KEY = 'queue';
const PLAYER_KEY = 'player';
// Only persist fields that make sense to restore. `duration`/`isPlaying` are
// derived from the <audio> element on next load, and the panel toggles are
// transient UI, so they are intentionally left out.
type PersistedQueue = Pick<
QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName'
>;
type PersistedPlayer = Pick<
PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle'
>;
function pickQueue(state: QueueState): PersistedQueue {
return {
entries: state.entries,
currentIndex: state.currentIndex,
source: state.source,
sourceId: state.sourceId,
sourceName: state.sourceName,
};
}
function pickPlayer(state: PlayerState): PersistedPlayer {
return {
currentTrackId: state.currentTrackId,
position: state.position,
volume: state.volume,
muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
};
}
function read<T>(key: string): Partial<T> | null {
try {
const raw = instanceStorage.get(key);
return raw ? (JSON.parse(raw) as Partial<T>) : null;
} catch {
return null;
}
}
/** Build the queue slice's initial state, restoring any persisted queue. */
export function loadQueueState(): QueueState {
const persisted = read<PersistedQueue>(QUEUE_KEY);
if (!persisted) return queueInitialState;
const merged: QueueState = { ...queueInitialState, ...persisted };
// Guard the index against a corrupted/short entries array.
if (
merged.currentIndex >= merged.entries.length ||
merged.currentIndex < -1
) {
merged.currentIndex = merged.entries.length ? 0 : -1;
}
return merged;
}
/** Build the player slice's initial state, restoring any persisted player. */
export function loadPlayerState(): PlayerState {
const persisted = read<PersistedPlayer>(PLAYER_KEY);
if (!persisted) return playerInitialState;
// Never auto-resume playback on load: browsers block autoplay and the
// <audio> element starts paused regardless. isPlaying stays false.
return { ...playerInitialState, ...persisted, isPlaying: false };
}
/**
* Subscribe a store so queue/player changes are flushed to localStorage. The
* write is throttled because `setPosition` fires several times a second during
* playback — without throttling we'd hammer localStorage on every tick.
*/
export function startPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
const initial = store.getState();
let lastQueue = JSON.stringify(pickQueue(initial.queue));
let lastPlayer = JSON.stringify(pickPlayer(initial.player));
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const state = store.getState();
const queueSnapshot = JSON.stringify(pickQueue(state.queue));
if (queueSnapshot !== lastQueue) {
instanceStorage.set(QUEUE_KEY, queueSnapshot);
lastQueue = queueSnapshot;
}
const playerSnapshot = JSON.stringify(pickPlayer(state.player));
if (playerSnapshot !== lastPlayer) {
instanceStorage.set(PLAYER_KEY, playerSnapshot);
lastPlayer = playerSnapshot;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 1000);
});
}
+86
View File
@@ -0,0 +1,86 @@
/*
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
* library/albums/artists/…) to the active backend's localStorage namespace and
* replay it into the cache on startup. With the backend down, components keep
* rendering the last-known data read-only instead of an error state.
*
* Per the architecture invariant, server data is NOT copied into a slice: this
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
*/
import { instanceStorage } from '../config/instances';
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
import type { RootState } from './index';
const CACHE_KEY = 'rtkq';
type ApiState = RootState['api'];
type QueryEntry = ApiState['queries'][string];
/**
* Keep only successfully-fulfilled query results — pending/rejected entries
* carry no usable data and subscriptions are rebuilt by components on mount.
* Mutation results are never restored.
*/
const EMPTY_PROVIDED = { tags: {}, keys: {} };
function snapshot(apiState: ApiState): RehydrateApiPayload {
const queries: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(apiState.queries)) {
const q = entry as QueryEntry | undefined;
if (q && q.status === 'fulfilled') queries[key] = q;
}
// Carry `provided` along so RTKQ can re-register invalidation tags for the
// restored entries; it is also required structurally (see RehydrateApiPayload).
return { queries, mutations: {}, provided: apiState.provided ?? EMPTY_PROVIDED };
}
function load(): RehydrateApiPayload | null {
try {
const raw = instanceStorage.get(CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
if (!parsed.queries) return null;
// `provided` may be absent in snapshots written before this field existed —
// default it so the invalidation slice doesn't crash on `provided.tags`.
return {
queries: parsed.queries,
mutations: {},
provided: parsed.provided ?? EMPTY_PROVIDED,
};
} catch {
return null;
}
}
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
const cached = load();
if (cached) dispatch(rehydrateApi(cached));
}
/**
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
* since cache state churns on every in-flight query transition.
*/
export function startApiPersistence(store: {
getState: () => RootState;
subscribe: (listener: () => void) => () => void;
}): () => void {
let last = '';
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const snap = JSON.stringify(snapshot(store.getState().api));
if (snap !== last) {
instanceStorage.set(CACHE_KEY, snap);
last = snap;
}
};
return store.subscribe(() => {
if (timer) return;
timer = setTimeout(flush, 2000);
});
}
+6 -2
View File
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
action: PayloadAction<{
accessToken: string;
refreshToken: string;
expiresIn: number;
expiresIn?: number;
}>,
) {
state.accessToken = action.payload.accessToken;
state.refreshToken = action.payload.refreshToken;
state.expiresAt = Date.now() + action.payload.expiresIn * 1000;
// Backends that omit a TTL leave expiresAt null — reauth is 401-driven.
state.expiresAt =
action.payload.expiresIn != null
? Date.now() + action.payload.expiresIn * 1000
: null;
persistAuth(state);
},
setUser(state, action: PayloadAction<User>) {
+5 -13
View File
@@ -1,8 +1,8 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type RepeatMode = 'none' | 'one' | 'all';
export type RepeatMode = 'none' | 'one' | 'all';
interface PlayerState {
export interface PlayerState {
currentTrackId: string | null;
isPlaying: boolean;
position: number;
@@ -11,11 +11,10 @@ interface PlayerState {
muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isNowPlayingOpen: boolean;
isQueueOpen: boolean;
}
const initialState: PlayerState = {
export const playerInitialState: PlayerState = {
currentTrackId: null,
isPlaying: false,
position: 0,
@@ -24,15 +23,12 @@ const initialState: PlayerState = {
muted: false,
repeat: 'none',
shuffle: false,
isNowPlayingOpen: false,
// STUB: open by default so the queue drawer look is visible before a backend
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
isQueueOpen: true,
isQueueOpen: false,
};
export const playerSlice = createSlice({
name: 'player',
initialState,
initialState: playerInitialState,
reducers: {
play(state, action: PayloadAction<string>) {
state.currentTrackId = action.payload;
@@ -68,9 +64,6 @@ export const playerSlice = createSlice({
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleNowPlaying(state) {
state.isNowPlayingOpen = !state.isNowPlayingOpen;
},
toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen;
},
@@ -88,7 +81,6 @@ export const {
toggleMute,
setRepeat,
toggleShuffle,
toggleNowPlaying,
toggleQueue,
} = playerSlice.actions;
export default playerSlice.reducer;
+9 -44
View File
@@ -1,6 +1,6 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
type QueueSource =
export type QueueSource =
| 'manual'
| 'album'
| 'playlist'
@@ -8,7 +8,7 @@ type QueueSource =
| 'search'
| 'radio';
interface QueueEntry {
export interface QueueEntry {
trackId: string;
title: string;
artistName: string;
@@ -17,7 +17,7 @@ interface QueueEntry {
albumArtUrl?: string;
}
interface QueueState {
export interface QueueState {
entries: QueueEntry[];
currentIndex: number;
source: QueueSource;
@@ -25,52 +25,17 @@ interface QueueState {
sourceName: string | null;
}
// STUB demo queue — purely client-side display data so the player bar and
// queue drawer render with content before the backend exists. Delete this
// block (reset entries/currentIndex/source to the empty values) once real
// playback wires tracks into the queue.
const DEMO_QUEUE: QueueEntry[] = [
{
trackId: 'd1',
title: 'Quiet Storage',
artistName: 'Cyan Atlas',
albumTitle: 'Night Index',
durationMs: 312_000,
},
{
trackId: 'd2',
title: 'Magnetic North',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 243_000,
},
{
trackId: 'd3',
title: 'Ambergris',
artistName: 'Møller',
albumTitle: 'Warm Static',
durationMs: 201_000,
},
{
trackId: 'd4',
title: 'Slow Carrier',
artistName: 'Tidal Bloom',
albumTitle: 'Ferric Coast',
durationMs: 301_000,
},
];
const initialState: QueueState = {
entries: DEMO_QUEUE,
currentIndex: 0,
source: 'radio',
export const queueInitialState: QueueState = {
entries: [],
currentIndex: -1,
source: 'manual',
sourceId: null,
sourceName: 'My radio',
sourceName: null,
};
export const queueSlice = createSlice({
name: 'queue',
initialState,
initialState: queueInitialState,
reducers: {
setQueue(
state,
+11
View File
@@ -4,12 +4,15 @@ interface UiState {
sidebarCollapsed: boolean;
activeModal: string | null;
activeTrackContextMenuId: string | null;
/** Track whose info drawer is open (rightmost drawer); null = closed. */
trackInfoId: string | null;
}
const initialState: UiState = {
sidebarCollapsed: false,
activeModal: null,
activeTrackContextMenuId: null,
trackInfoId: null,
};
export const uiSlice = createSlice({
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
setActiveContextMenu(state, action: PayloadAction<string | null>) {
state.activeTrackContextMenuId = action.payload;
},
openTrackInfo(state, action: PayloadAction<string>) {
state.trackInfoId = action.payload;
},
closeTrackInfo(state) {
state.trackInfoId = null;
},
},
});
@@ -40,5 +49,7 @@ export const {
openModal,
closeModal,
setActiveContextMenu,
openTrackInfo,
closeTrackInfo,
} = uiSlice.actions;
export default uiSlice.reducer;
+5
View File
@@ -28,6 +28,11 @@ body {
margin: 0;
font-family: var(--font-sans);
color: var(--fg-1);
/* Paint the themed background immediately. The inline theme script in
index.html (see rsbuild.config.ts) sets [data-theme] before first paint, so
--color-bg resolves to the right value here before React mounts #root and
layers the .modern-sk-felt grain on top — no flash of white. */
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
}
+216 -1
View File
@@ -53,12 +53,14 @@
display: flex;
flex-direction: column;
min-height: 0;
overflow-x: hidden;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
border-right: 1px solid var(--hair);
}
.sb-scroll {
flex: 1;
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
overflow-x: hidden;
overflow-y: auto;
padding: 14px 12px 6px;
}
@@ -90,6 +92,8 @@
align-items: center;
gap: 11px;
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: 8px 10px;
border-radius: var(--r-md);
font-size: 14px;
@@ -107,6 +111,14 @@
.nav-item .ph {
font-size: 18px;
color: var(--fg-3);
flex-shrink: 0;
}
.nav-item > span {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
@@ -150,6 +162,8 @@
align-items: center;
gap: 9px;
width: 100%;
box-sizing: border-box;
min-width: 0;
padding: 6px 10px;
border-radius: var(--r-md);
font-size: 13px;
@@ -263,9 +277,11 @@
/* connection status pill (used in sidebar foot) */
.conn {
display: inline-flex;
display: flex;
align-items: center;
gap: 7px;
width: 100%;
box-sizing: border-box;
padding: 5px 11px 5px 9px;
border-radius: var(--r-pill);
font-size: 12px;
@@ -707,3 +723,202 @@
color: var(--fg-3);
font-size: 12px;
}
/* ============================================================
TRACK INFO DRAWER (rightmost — sits right of the queue drawer)
============================================================ */
/* Same width-collapse pattern as .qd. Rendered after QueuePanel in AppShell so
when both are open this is the rightmost panel. */
.tid {
width: 360px;
flex-shrink: 0;
overflow: hidden;
border-left: 1px solid var(--hair);
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.24));
transition:
width 0.24s var(--ease-out),
border-left-color 0.24s var(--ease-out);
}
.tid.closed {
width: 0;
border-left-color: transparent;
}
.tid-inner {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
}
.tid-head {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 16px 18px 12px;
border-bottom: 1px solid var(--hair);
}
.tid-head h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--fg-1);
}
.tid-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 18px;
}
.tid-cover {
width: 100%;
aspect-ratio: 1 / 1;
margin-bottom: 16px;
border-radius: 12px;
overflow: hidden;
background: var(--steel-900);
box-shadow: var(--shadow-raised, 0 8px 24px rgba(0, 0, 0, 0.4));
}
.tid-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tid-title {
margin: 0 0 4px;
font-size: 19px;
font-weight: 700;
color: var(--fg-1);
line-height: 1.25;
}
.tid-sub {
display: block;
font-size: 13px;
color: var(--fg-2);
text-decoration: none;
}
.tid-sub:hover {
color: var(--lime);
text-decoration: underline;
}
.tid-album {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
color: var(--fg-3);
}
.tid-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 4px;
}
.tid-section {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--hair);
}
.tid-section-label {
display: block;
margin-bottom: 10px;
}
.tid-status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.tid-error {
margin: 8px 0 0;
font-size: 12px;
color: var(--ember, #e9572b);
}
.tid-row {
display: flex;
gap: 12px;
padding: 5px 0;
font-size: 13px;
}
.tid-row-k {
flex-shrink: 0;
width: 96px;
color: var(--fg-3);
}
.tid-row-v {
min-width: 0;
flex: 1;
color: var(--fg-1);
text-align: right;
word-break: break-word;
}
.tid-row-v.mono {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
color: var(--fg-2);
}
/* On narrower viewports the drawer overlays the content instead of pushing it,
so the queue + info drawers don't squeeze the main screen. */
@media (max-width: 1180px) {
.app-body {
position: relative;
}
.tid {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
z-index: 30;
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.5);
transition: transform 0.24s var(--ease-out);
}
.tid.closed {
width: 360px;
transform: translateX(100%);
box-shadow: none;
}
}
/* ============================================================
PAGE HEADER + SECONDARY NAV (Settings, Admin)
============================================================ */
.page-title {
margin: 0;
font-family: var(--font-display, var(--font-sans));
font-size: 22px;
font-weight: 700;
letter-spacing: var(--track-snug);
color: var(--fg-1);
}
.sub-nav {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--hair);
}
.sub-nav-item {
padding: 8px 12px;
font-size: 14px;
font-weight: 500;
color: var(--fg-2);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition:
color 0.12s ease,
border-color 0.12s ease;
}
.sub-nav-item:hover {
color: var(--fg-1);
}
.sub-nav-item.active {
color: var(--fg-1);
border-bottom-color: var(--lime);
}
/* Sidebar section header that doubles as a link (Playlists) */
.sb-sec-link.active {
color: var(--fg-1);
}
+120
View File
@@ -0,0 +1,120 @@
// @rstest-environment jsdom
import { expect, test, beforeEach, rstest } from '@rstest/core';
import {
loadQueueState,
loadPlayerState,
startPersistence,
} from '../src/store/persist';
import { queueInitialState, type QueueState } from '../src/store/slices/queue';
import {
playerInitialState,
type PlayerState,
} from '../src/store/slices/player';
import {
upsertInstance,
setActiveInstanceId,
instanceStorage,
} from '../src/config/instances';
beforeEach(() => {
localStorage.clear();
const inst = upsertInstance('http://test.local');
setActiveInstanceId(inst.id);
});
const sampleQueue: QueueState = {
entries: [
{
trackId: 't1',
title: 'A',
artistName: 'X',
albumTitle: 'Alb',
durationMs: 1000,
},
{
trackId: 't2',
title: 'B',
artistName: 'Y',
albumTitle: 'Alb',
durationMs: 2000,
},
],
currentIndex: 1,
source: 'album',
sourceId: 'alb-1',
sourceName: 'My Album',
};
test('loaders fall back to initial state with nothing persisted', () => {
expect(loadQueueState()).toEqual(queueInitialState);
expect(loadPlayerState()).toEqual(playerInitialState);
});
test('loadQueueState restores a persisted queue', () => {
instanceStorage.set('queue', JSON.stringify(sampleQueue));
expect(loadQueueState()).toEqual(sampleQueue);
});
test('loadQueueState guards a currentIndex past the entries array', () => {
instanceStorage.set(
'queue',
JSON.stringify({ ...sampleQueue, currentIndex: 99 }),
);
expect(loadQueueState().currentIndex).toBe(0);
});
test('loadPlayerState restores fields but never auto-resumes playback', () => {
instanceStorage.set(
'player',
JSON.stringify({
currentTrackId: 't2',
position: 42,
volume: 0.5,
muted: true,
repeat: 'all',
shuffle: true,
// a stale isPlaying:true must not survive a reload
isPlaying: true,
}),
);
const loaded = loadPlayerState();
expect(loaded.currentTrackId).toBe('t2');
expect(loaded.position).toBe(42);
expect(loaded.volume).toBe(0.5);
expect(loaded.repeat).toBe('all');
expect(loaded.isPlaying).toBe(false);
});
test('corrupt JSON falls back to initial state', () => {
instanceStorage.set('queue', '{not json');
expect(loadQueueState()).toEqual(queueInitialState);
});
test('startPersistence flushes changed state to storage after throttle', () => {
rstest.useFakeTimers();
let state = {
queue: queueInitialState,
player: playerInitialState,
} as { queue: QueueState; player: PlayerState };
let listener: (() => void) | null = null;
const store = {
getState: () => state as never,
subscribe: (l: () => void) => {
listener = l;
return () => {};
},
};
startPersistence(store);
// mutate + notify
state = { ...state, queue: sampleQueue };
listener!();
// nothing written before the throttle window elapses
expect(instanceStorage.get('queue')).toBeNull();
rstest.advanceTimersByTime(1000);
expect(JSON.parse(instanceStorage.get('queue')!).currentIndex).toBe(1);
rstest.useRealTimers();
});
+99
View File
@@ -0,0 +1,99 @@
// @rstest-environment jsdom
import { expect, test, beforeEach, rstest } from '@rstest/core';
import {
rehydrateApiCache,
startApiPersistence,
} from '../src/store/rtkqPersist';
import { REHYDRATE_API } from '../src/api/rehydrate';
import {
upsertInstance,
setActiveInstanceId,
instanceStorage,
} from '../src/config/instances';
import type { RootState } from '../src/store/index';
beforeEach(() => {
localStorage.clear();
const inst = upsertInstance('http://test.local');
setActiveInstanceId(inst.id);
});
function apiStateWith(queries: Record<string, unknown>) {
return {
api: { queries, mutations: {}, provided: {}, subscriptions: {}, config: {} },
} as unknown as RootState;
}
test('rehydrateApiCache dispatches nothing when no cache is stored', () => {
const dispatched: unknown[] = [];
rehydrateApiCache((a) => dispatched.push(a));
expect(dispatched).toHaveLength(0);
});
test('rehydrateApiCache replays a stored cache as a rehydrate action', () => {
instanceStorage.set(
'rtkq',
JSON.stringify({
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [1] } },
mutations: {},
}),
);
const dispatched: Array<{ type: string; payload: unknown }> = [];
rehydrateApiCache((a) =>
dispatched.push(a as { type: string; payload: unknown }),
);
expect(dispatched).toHaveLength(1);
expect(dispatched[0].type).toBe(REHYDRATE_API);
expect(dispatched[0].payload).toMatchObject({
queries: { 'getLibrary(undefined)': { status: 'fulfilled' } },
});
});
test('rehydrate payload always carries `provided` (regression: RTKQ reads provided.tags)', () => {
// A snapshot persisted before `provided` existed must not crash RTKQ's
// invalidation slice, which does `Object.entries(provided.tags ?? {})`.
instanceStorage.set(
'rtkq',
JSON.stringify({
queries: { 'getLibrary(undefined)': { status: 'fulfilled', data: [] } },
mutations: {},
}),
);
const dispatched: Array<{ payload: { provided?: unknown } }> = [];
rehydrateApiCache((a) =>
dispatched.push(a as { payload: { provided?: unknown } }),
);
expect(dispatched[0].payload.provided).toEqual({ tags: {}, keys: {} });
});
test('startApiPersistence saves only fulfilled queries after throttle', () => {
rstest.useFakeTimers();
let state = apiStateWith({});
let listener: (() => void) | null = null;
const store = {
getState: () => state,
subscribe: (l: () => void) => {
listener = l;
return () => {};
},
};
startApiPersistence(store);
state = apiStateWith({
'getAlbums(undefined)': { status: 'fulfilled', data: ['a'] },
'getArtists(undefined)': { status: 'pending' },
'getTracks(undefined)': { status: 'rejected', error: 'boom' },
});
listener!();
// throttled — nothing yet
expect(instanceStorage.get('rtkq')).toBeNull();
rstest.advanceTimersByTime(2000);
const saved = JSON.parse(instanceStorage.get('rtkq')!);
expect(Object.keys(saved.queries)).toEqual(['getAlbums(undefined)']);
expect(saved.mutations).toEqual({});
rstest.useRealTimers();
});
+79
View File
@@ -0,0 +1,79 @@
import { expect, test } from '@rstest/core';
import {
trackIdFromUrl,
cacheKeyFor,
parseRangeHeader,
selectEvictions,
} from '../public/sw-core.js';
test('trackIdFromUrl extracts the content id from a stream URL', () => {
expect(
trackIdFromUrl('https://host/api/v1/stream/abc123?token=xyz'),
).toBe('abc123');
expect(trackIdFromUrl('https://host/api/v1/library/albums')).toBeNull();
});
test('cacheKeyFor strips the token so the key is token-stable', () => {
const a = cacheKeyFor('https://host/api/v1/stream/t1?token=AAA');
const b = cacheKeyFor('https://host/api/v1/stream/t1?token=BBB');
expect(a).toBe(b);
expect(a).toBe('https://host/api/v1/stream/t1');
});
test('cacheKeyFor keeps different origins distinct', () => {
expect(cacheKeyFor('https://a/stream/t1?token=x')).not.toBe(
cacheKeyFor('https://b/stream/t1?token=x'),
);
});
test('parseRangeHeader: closed range', () => {
expect(parseRangeHeader('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 });
});
test('parseRangeHeader: open-ended range clamps to size', () => {
expect(parseRangeHeader('bytes=500-', 1000)).toEqual({
start: 500,
end: 999,
});
});
test('parseRangeHeader: suffix range (last N bytes)', () => {
expect(parseRangeHeader('bytes=-200', 1000)).toEqual({
start: 800,
end: 999,
});
});
test('parseRangeHeader: end past size is clamped', () => {
expect(parseRangeHeader('bytes=900-5000', 1000)).toEqual({
start: 900,
end: 999,
});
});
test('parseRangeHeader: invalid / no range returns null', () => {
expect(parseRangeHeader('', 1000)).toBeNull();
expect(parseRangeHeader('items=0-1', 1000)).toBeNull();
expect(parseRangeHeader('bytes=500-100', 1000)).toBeNull();
});
test('selectEvictions: nothing evicted when under cap', () => {
const index = {
a: { size: 100, lastAccess: 1 },
b: { size: 100, lastAccess: 2 },
};
expect(selectEvictions(index, 100, 1000)).toEqual([]);
});
test('selectEvictions: evicts least-recently-used first until it fits', () => {
const index = {
a: { size: 400, lastAccess: 10 }, // oldest
b: { size: 400, lastAccess: 30 },
c: { size: 400, lastAccess: 20 },
};
// total 1200 + incoming 400 = 1600, cap 1000 → must free >=600.
// LRU order: a (10), c (20). Evict a (1200→800... wait incl incoming)
const evicted = selectEvictions(index, 400, 1000);
// total with incoming = 1600; evict a → 1200; evict c → 800 <= 1000.
expect(evicted).toEqual(['a', 'c']);
});