Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b2b40ffd | |||
| 8a70f478c3 | |||
| 9c344b98c4 | |||
| 42080b37ea | |||
| a37c19fd45 | |||
| facc215450 | |||
| 98e9344261 | |||
| 1228118027 | |||
| 538cfb9c5b | |||
| 2ad3b128d6 | |||
| 55aa8933af | |||
| dacb8b9278 | |||
| bcfb36d53e | |||
| 451dbb94a8 | |||
| af0d8e7646 | |||
| f712f871f1 | |||
| a2fa425853 | |||
| ceee9b9d12 | |||
| 61dbb1abd2 | |||
| aed0572071 | |||
| e45bcef3a5 | |||
| bbd59cc225 |
@@ -1,2 +1,6 @@
|
|||||||
# Default backend URL (overridable at runtime in the UI)
|
# Default backend URL (overridable at runtime in the UI)
|
||||||
PUBLIC_API_BASE_URL=http://localhost:8080/api/v1
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
Executable
+26
@@ -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"
|
||||||
@@ -4,14 +4,9 @@
|
|||||||
# shadows the container install. Build context = mcma-webui/.
|
# shadows the container install. Build context = mcma-webui/.
|
||||||
FROM node:22-slim
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json modern-sk-*.tgz ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ FROM node:22-slim AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json modern-sk-*.tgz ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Bake the API base URL at build time (rsbuild inlines PUBLIC_* vars).
|
# Build-time default for the API base URL (rsbuild inlines PUBLIC_* vars). This
|
||||||
# Same-origin default ('/api/v1') works behind any reverse proxy.
|
# 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
|
ARG PUBLIC_API_BASE_URL=/api/v1
|
||||||
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
|
ENV PUBLIC_API_BASE_URL=$PUBLIC_API_BASE_URL
|
||||||
RUN npm run build
|
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 dockerfiles/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
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
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ server {
|
|||||||
try_files $uri =404;
|
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).
|
# SPA: every unknown path falls back to index.html (client-side router).
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Generated
+91
-53
@@ -8,11 +8,13 @@
|
|||||||
"name": "mcma-webui",
|
"name": "mcma-webui",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"modern-sk": "file:./modern-sk-0.1.2.tgz",
|
"i18next": "^26.3.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0"
|
||||||
},
|
},
|
||||||
@@ -496,7 +498,6 @@
|
|||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -691,6 +692,20 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@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": {
|
"node_modules/@phosphor-icons/react": {
|
||||||
"version": "2.1.10",
|
"version": "2.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
|
||||||
@@ -2464,9 +2479,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2481,9 +2493,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2498,9 +2507,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2515,9 +2521,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2818,9 +2821,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2838,9 +2838,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2858,9 +2855,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2878,9 +2872,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3501,6 +3492,43 @@
|
|||||||
"node": ">=20.0.0"
|
"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": {
|
"node_modules/immer": {
|
||||||
"version": "11.1.8",
|
"version": "11.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
@@ -3707,9 +3735,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3731,9 +3756,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3755,9 +3777,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3779,9 +3798,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3877,20 +3893,6 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -4105,6 +4107,33 @@
|
|||||||
"react": "^19.2.7"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
@@ -4371,7 +4400,7 @@
|
|||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -4471,6 +4500,15 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -13,11 +13,13 @@
|
|||||||
"test:watch": "rstest --watch"
|
"test:watch": "rstest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@olly/modern-sk": "^0.1.5",
|
||||||
"@phosphor-icons/react": "^2.1.10",
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@reduxjs/toolkit": "^2.12.0",
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"modern-sk": "file:./modern-sk-0.1.2.tgz",
|
"i18next": "^26.3.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"react-redux": "^9.3.0",
|
"react-redux": "^9.3.0",
|
||||||
"react-router": "^7.16.0"
|
"react-router": "^7.16.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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__ = {};
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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) });
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -32,5 +32,38 @@ export default defineConfig({
|
|||||||
// no manual source.define needed. See src/config/env.ts.
|
// no manual source.define needed. See src/config/env.ts.
|
||||||
html: {
|
html: {
|
||||||
title: 'MCMA',
|
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
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
|
|||||||
{
|
{
|
||||||
url: '/auth/refresh',
|
url: '/auth/refresh',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { refreshToken },
|
body: { refresh_token: refreshToken },
|
||||||
},
|
},
|
||||||
api,
|
api,
|
||||||
extraOptions,
|
extraOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (refreshResult.data) {
|
if (refreshResult.data) {
|
||||||
const {
|
// Backend wire format is snake_case with no TTL (see auth.ts adapter).
|
||||||
accessToken,
|
const { access_token, refresh_token } = refreshResult.data as {
|
||||||
refreshToken: newRefresh,
|
access_token: string;
|
||||||
expiresIn,
|
refresh_token: string;
|
||||||
} = refreshResult.data as {
|
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresIn: number;
|
|
||||||
};
|
};
|
||||||
api.dispatch(
|
api.dispatch(
|
||||||
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
|
setTokens({
|
||||||
|
accessToken: access_token,
|
||||||
|
refreshToken: refresh_token,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
result = await rawBaseQuery()(args, api, extraOptions);
|
result = await rawBaseQuery()(args, api, extraOptions);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+21
-10
@@ -1,33 +1,44 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
|
import { toUser, type RawUser } from '../mappers';
|
||||||
import type { User } from '../types';
|
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({
|
export const adminApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getUsers: build.query<User[], void>({
|
getUsers: build.query<User[], void>({
|
||||||
query: () => '/admin/users',
|
query: () => '/admin/users',
|
||||||
|
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||||
providesTags: ['User'],
|
providesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
createUser: build.mutation<
|
createUser: build.mutation<
|
||||||
User,
|
User,
|
||||||
{
|
{ username: string; password: string; role: 'admin' | 'user' }
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
email?: 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'],
|
invalidatesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
updateUser: build.mutation<
|
updateUser: build.mutation<
|
||||||
User,
|
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}`,
|
url: `/admin/users/${id}`,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body,
|
body: {
|
||||||
|
is_superuser: role === undefined ? undefined : role === 'admin',
|
||||||
|
is_active: isActive,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
transformResponse: (raw: RawUser) => toUser(raw),
|
||||||
invalidatesTags: ['User'],
|
invalidatesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
deleteUser: build.mutation<void, string>({
|
deleteUser: build.mutation<void, string>({
|
||||||
|
|||||||
+95
-11
@@ -1,26 +1,110 @@
|
|||||||
import { api } from '../index';
|
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({
|
export const authApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
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>({
|
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>({
|
// Sign-up mirrors login: POST /auth/register returns a token pair (the
|
||||||
query: () => ({ url: '/auth/logout', method: 'POST' }),
|
// 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<
|
logout: build.mutation<void, { refreshToken: string }>({
|
||||||
{ accessToken: string; refreshToken: string; expiresIn: number },
|
query: ({ refreshToken }) => ({
|
||||||
{ refreshToken: string }
|
url: '/auth/logout',
|
||||||
>({
|
method: 'POST',
|
||||||
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
|
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',
|
query: () => '/auth/me',
|
||||||
|
transformResponse: (raw: RawUser) => toUser(raw),
|
||||||
providesTags: ['User'],
|
providesTags: ['User'],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: false,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
|
export const {
|
||||||
|
useLoginMutation,
|
||||||
|
useRegisterMutation,
|
||||||
|
useLogoutMutation,
|
||||||
|
useRefreshTokenMutation,
|
||||||
|
useMeQuery,
|
||||||
|
} = authApi;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import type { DownloadJob } from '../types';
|
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({
|
export const downloadsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getDownloads: build.query<
|
getDownloads: build.query<
|
||||||
|
|||||||
@@ -1,16 +1,61 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
|
import {
|
||||||
|
toAlbum,
|
||||||
|
toArtist,
|
||||||
|
toMetadataMatch,
|
||||||
|
toPage,
|
||||||
|
toTrack,
|
||||||
|
type RawAlbum,
|
||||||
|
type RawArtist,
|
||||||
|
type RawMetadataMatch,
|
||||||
|
type RawPaged,
|
||||||
|
type RawTrack,
|
||||||
|
} from '../mappers';
|
||||||
import type {
|
import type {
|
||||||
Track,
|
Track,
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
|
MetadataEdit,
|
||||||
|
MetadataMatch,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
} from '../types';
|
} 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({
|
export const libraryApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
|
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) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [
|
? [
|
||||||
@@ -20,7 +65,8 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
: ['Track'],
|
: ['Track'],
|
||||||
}),
|
}),
|
||||||
getTrack: build.query<Track, string>({
|
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 }],
|
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
|
||||||
}),
|
}),
|
||||||
getAlbums: build.query<
|
getAlbums: build.query<
|
||||||
@@ -32,7 +78,15 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} | void
|
} | 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) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [
|
? [
|
||||||
@@ -42,11 +96,13 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
: ['Album'],
|
: ['Album'],
|
||||||
}),
|
}),
|
||||||
getAlbum: build.query<Album, string>({
|
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 }],
|
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
|
||||||
}),
|
}),
|
||||||
getAlbumTracks: build.query<Track[], string>({
|
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) => [
|
providesTags: (_r, _e, albumId) => [
|
||||||
{ type: 'Album', id: albumId },
|
{ type: 'Album', id: albumId },
|
||||||
'Track',
|
'Track',
|
||||||
@@ -56,7 +112,11 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
PaginatedResponse<Artist>,
|
PaginatedResponse<Artist>,
|
||||||
{ search?: string; page?: number; pageSize?: number } | void
|
{ 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) =>
|
providesTags: (result) =>
|
||||||
result
|
result
|
||||||
? [
|
? [
|
||||||
@@ -69,23 +129,77 @@ export const libraryApi = api.injectEndpoints({
|
|||||||
: ['Artist'],
|
: ['Artist'],
|
||||||
}),
|
}),
|
||||||
getArtist: build.query<Artist, string>({
|
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 }],
|
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
|
||||||
}),
|
}),
|
||||||
getArtistAlbums: build.query<Album[], string>({
|
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) => [
|
providesTags: (_r, _e, artistId) => [
|
||||||
{ type: 'Artist', id: artistId },
|
{ type: 'Artist', id: artistId },
|
||||||
'Album',
|
'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<
|
searchLibrary: build.query<
|
||||||
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
|
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
|
||||||
string
|
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'],
|
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,
|
overrideExisting: false,
|
||||||
});
|
});
|
||||||
@@ -99,5 +213,9 @@ export const {
|
|||||||
useGetArtistsQuery,
|
useGetArtistsQuery,
|
||||||
useGetArtistQuery,
|
useGetArtistQuery,
|
||||||
useGetArtistAlbumsQuery,
|
useGetArtistAlbumsQuery,
|
||||||
|
useGetArtistTracksQuery,
|
||||||
useSearchLibraryQuery,
|
useSearchLibraryQuery,
|
||||||
|
useLazyGetMetadataMatchesQuery,
|
||||||
|
useApplyMetadataMutation,
|
||||||
|
useEnrichTrackMutation,
|
||||||
} = libraryApi;
|
} = libraryApi;
|
||||||
|
|||||||
+58
-10
@@ -1,20 +1,68 @@
|
|||||||
import { api } from '../index';
|
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({
|
export const likesApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
likeTrack: build.mutation<void, string>({
|
setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
|
||||||
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
|
query: ({ trackId, value }) => ({
|
||||||
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
url: '/likes',
|
||||||
}),
|
method: 'POST',
|
||||||
unlikeTrack: build.mutation<void, string>({
|
body: { track_id: trackId, value },
|
||||||
query: (trackId) => ({
|
|
||||||
url: `/likes/tracks/${trackId}`,
|
|
||||||
method: 'DELETE',
|
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
transformResponse: (raw: RawLikeState) => toLikeState(raw),
|
||||||
|
invalidatesTags: (_r, _e, { trackId }) => [
|
||||||
|
'Like',
|
||||||
|
{ type: 'Track', id: trackId },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
// 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,
|
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 };
|
||||||
|
|||||||
@@ -1,36 +1,61 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
|
import {
|
||||||
|
toPage,
|
||||||
|
toPlaylist,
|
||||||
|
toTrack,
|
||||||
|
type RawPaged,
|
||||||
|
type RawPlaylist,
|
||||||
|
type RawTrack,
|
||||||
|
} from '../mappers';
|
||||||
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
|
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
|
||||||
|
|
||||||
export const playlistsApi = api.injectEndpoints({
|
export const playlistsApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
|
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
|
||||||
query: () => '/playlists',
|
query: () => '/playlists',
|
||||||
|
transformResponse: (raw: RawPaged<RawPlaylist>) =>
|
||||||
|
toPage(raw, toPlaylist),
|
||||||
providesTags: ['Playlist'],
|
providesTags: ['Playlist'],
|
||||||
}),
|
}),
|
||||||
getPlaylist: build.query<Playlist, string>({
|
getPlaylist: build.query<Playlist, string>({
|
||||||
query: (id) => `/playlists/${id}`,
|
query: (id) => `/playlists/${id}`,
|
||||||
|
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
|
||||||
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
|
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
|
||||||
}),
|
}),
|
||||||
getPlaylistTracks: build.query<PlaylistTrack[], string>({
|
getPlaylistTracks: build.query<PlaylistTrack[], string>({
|
||||||
query: (id) => `/playlists/${id}/tracks`,
|
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'],
|
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
|
||||||
}),
|
}),
|
||||||
createPlaylist: build.mutation<
|
createPlaylist: build.mutation<
|
||||||
Playlist,
|
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'],
|
invalidatesTags: ['Playlist'],
|
||||||
}),
|
}),
|
||||||
updatePlaylist: build.mutation<
|
updatePlaylist: build.mutation<
|
||||||
Playlist,
|
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}`,
|
url: `/playlists/${id}`,
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body,
|
body: { name, description },
|
||||||
}),
|
}),
|
||||||
|
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
|
||||||
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
|
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
|
||||||
}),
|
}),
|
||||||
deletePlaylist: build.mutation<void, string>({
|
deletePlaylist: build.mutation<void, string>({
|
||||||
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
addTrackToPlaylist: build.mutation<
|
addTrackToPlaylist: build.mutation<
|
||||||
void,
|
void,
|
||||||
{ playlistId: string; trackId: string }
|
{ playlistId: string; trackId: string; position?: number }
|
||||||
>({
|
>({
|
||||||
query: ({ playlistId, trackId }) => ({
|
query: ({ playlistId, trackId, position }) => ({
|
||||||
url: `/playlists/${playlistId}/tracks`,
|
url: `/playlists/${playlistId}/tracks`,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { trackId },
|
body: { track_id: trackId, position },
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, _e, { playlistId }) => [
|
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||||
{ type: 'Playlist', id: playlistId },
|
{ type: 'Playlist', id: playlistId },
|
||||||
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
|
|||||||
}),
|
}),
|
||||||
removeTrackFromPlaylist: build.mutation<
|
removeTrackFromPlaylist: build.mutation<
|
||||||
void,
|
void,
|
||||||
{ playlistId: string; trackId: string; position: number }
|
{ playlistId: string; trackId: string }
|
||||||
>({
|
>({
|
||||||
query: ({ playlistId, position }) => ({
|
query: ({ playlistId, trackId }) => ({
|
||||||
url: `/playlists/${playlistId}/tracks/${position}`,
|
url: `/playlists/${playlistId}/tracks/${trackId}`,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
invalidatesTags: (_r, _e, { playlistId }) => [
|
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { api } from '../index';
|
import { api } from '../index';
|
||||||
import type { StorageStats } from '../types';
|
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({
|
export const storageApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
getStorageStats: build.query<StorageStats, void>({
|
getStorageStats: build.query<StorageStats, void>({
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { getApiBaseUrl } from '../../config/runtime-config';
|
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 {
|
export function getStreamUrl(trackId: string, token: string): string {
|
||||||
const base = getApiBaseUrl();
|
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 {
|
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||||
@@ -12,3 +17,18 @@ export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
|||||||
const base = getApiBaseUrl();
|
const base = getApiBaseUrl();
|
||||||
return `${base}${artUrl}`;
|
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)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { api } from '../index';
|
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({
|
export const uploadApi = api.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
uploadTrack: build.mutation<Track, FormData>({
|
uploadTrack: build.mutation<UploadResponse, FormData>({
|
||||||
query: (formData) => ({
|
query: (formData) => ({
|
||||||
url: '/upload',
|
url: '/upload',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||||
import { baseQueryWithReauth } from './baseQuery';
|
import { baseQueryWithReauth } from './baseQuery';
|
||||||
|
import { REHYDRATE_API, type RehydrateApiPayload } from './rehydrate';
|
||||||
|
|
||||||
export const api = createApi({
|
export const api = createApi({
|
||||||
reducerPath: 'api',
|
reducerPath: 'api',
|
||||||
@@ -14,5 +15,16 @@ export const api = createApi({
|
|||||||
'User',
|
'User',
|
||||||
'Storage',
|
'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: () => ({}),
|
endpoints: () => ({}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
@@ -1,5 +1,13 @@
|
|||||||
export type TrackAvailability = 'server' | 'downloading' | 'error' | 'missing';
|
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 {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,16 +16,26 @@ export interface Track {
|
|||||||
albumId: string;
|
albumId: string;
|
||||||
albumTitle: string;
|
albumTitle: string;
|
||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
|
hasCover: boolean;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
trackNumber?: number;
|
trackNumber?: number;
|
||||||
discNumber?: number;
|
discNumber?: number;
|
||||||
year?: number;
|
year?: number;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
availability: TrackAvailability;
|
availability: TrackAvailability;
|
||||||
|
metadataStatus: MetadataStatus;
|
||||||
|
/** Human-readable reason the last enrichment run set `failed`; else undefined. */
|
||||||
|
metadataError?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
format?: string;
|
format?: string;
|
||||||
bitrate?: number;
|
bitrate?: number;
|
||||||
liked: boolean;
|
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 {
|
export interface Album {
|
||||||
@@ -72,6 +90,12 @@ export interface DownloadJob {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
track_id: string;
|
||||||
|
title: string;
|
||||||
|
already_exists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageStats {
|
export interface StorageStats {
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
usedBytes: number;
|
usedBytes: number;
|
||||||
@@ -92,7 +116,9 @@ export interface User {
|
|||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: 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 {
|
export interface LoginRequest {
|
||||||
@@ -105,6 +131,11 @@ export interface LoginResponse {
|
|||||||
tokens: AuthTokens;
|
tokens: AuthTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -130,3 +161,26 @@ export interface ApiError {
|
|||||||
message: string;
|
message: string;
|
||||||
code?: 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { getApiBaseUrl } from '../../config/runtime-config';
|
import { getApiBaseUrl } from '../../config/runtime-config';
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_KEY = {
|
||||||
connected: 'Connected',
|
connected: 'conn.connected',
|
||||||
connecting: 'Connecting…',
|
connecting: 'conn.connecting',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'conn.disconnected',
|
||||||
error: 'Connection error',
|
error: 'conn.error',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const STATUS_VARIANTS = {
|
const STATUS_VARIANTS = {
|
||||||
@@ -17,13 +18,21 @@ const STATUS_VARIANTS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function ConnectionStatus() {
|
export function ConnectionStatus() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const status = useConnectionStatus();
|
const status = useConnectionStatus();
|
||||||
const baseUrl = getApiBaseUrl();
|
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 (
|
return (
|
||||||
<Tooltip content={`${STATUS_LABELS[status]} · ${baseUrl}`}>
|
<Tooltip content={tip}>
|
||||||
<Badge variant={STATUS_VARIANTS[status]} dot>
|
<Badge variant={STATUS_VARIANTS[status]} dot>
|
||||||
{STATUS_LABELS[status]}
|
{label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { Callout, Button } from 'modern-sk';
|
import { Callout, Button } from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ErrorStateProps {
|
interface ErrorStateProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
onRetry?: () => void;
|
onRetry?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorState({
|
export function ErrorState({ message, onRetry }: ErrorStateProps) {
|
||||||
message = 'Something went wrong',
|
const { t } = useTranslation();
|
||||||
onRetry,
|
|
||||||
}: ErrorStateProps) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '2rem' }}>
|
||||||
<Callout variant="danger">
|
<Callout variant="danger">
|
||||||
{message}
|
{message ?? t('common.error')}
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -20,7 +19,7 @@ export function ErrorState({
|
|||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
style={{ marginLeft: '1rem' }}
|
style={{ marginLeft: '1rem' }}
|
||||||
>
|
>
|
||||||
Retry
|
{t('common.retry')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Callout>
|
</Callout>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
GearSix,
|
GearSix,
|
||||||
HardDrives,
|
HardDrives,
|
||||||
Heart,
|
Heart,
|
||||||
House,
|
Info,
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'vinyl-record': VinylRecord,
|
'vinyl-record': VinylRecord,
|
||||||
house: House,
|
|
||||||
'magnifying-glass': MagnifyingGlass,
|
'magnifying-glass': MagnifyingGlass,
|
||||||
'arrow-circle-down': ArrowCircleDown,
|
'arrow-circle-down': ArrowCircleDown,
|
||||||
'upload-simple': UploadSimple,
|
'upload-simple': UploadSimple,
|
||||||
@@ -71,6 +70,7 @@ const ICONS = {
|
|||||||
'skip-forward': SkipForward,
|
'skip-forward': SkipForward,
|
||||||
repeat: Repeat,
|
repeat: Repeat,
|
||||||
heart: Heart,
|
heart: Heart,
|
||||||
|
info: Info,
|
||||||
'thumbs-down': ThumbsDown,
|
'thumbs-down': ThumbsDown,
|
||||||
'speaker-high': SpeakerHigh,
|
'speaker-high': SpeakerHigh,
|
||||||
'speaker-x': SpeakerSimpleX,
|
'speaker-x': SpeakerSimpleX,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Outlet } from 'react-router';
|
import { Outlet } from 'react-router';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { PersistentPlayer } from '../player/PersistentPlayer';
|
import { PersistentPlayer } from '../player/PersistentPlayer';
|
||||||
import { QueuePanel } from '../player/QueuePanel';
|
import { QueuePanel } from '../player/QueuePanel';
|
||||||
|
import { TrackInfoDrawer } from '../track/TrackInfoDrawer';
|
||||||
|
import { LoadingSkeleton } from '../common/LoadingSkeleton';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
return (
|
return (
|
||||||
@@ -17,10 +20,19 @@ export function AppShell() {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<div className="app-screen">
|
<div className="app-screen">
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div style={{ padding: '2rem' }}>
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<QueuePanel />
|
<QueuePanel />
|
||||||
|
<TrackInfoDrawer />
|
||||||
</div>
|
</div>
|
||||||
<PersistentPlayer />
|
<PersistentPlayer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NavLink, useNavigate } from 'react-router';
|
import { NavLink, useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon, type IconName } from '../common/Icon';
|
import { Icon, type IconName } from '../common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { usePermissions } from '../../hooks/usePermissions';
|
import { usePermissions, type Permission } from '../../hooks/usePermissions';
|
||||||
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { logout } from '../../store/slices/auth';
|
import { logout } from '../../store/slices/auth';
|
||||||
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
import { useGetPlaylistsQuery } from '../../api/endpoints/playlists';
|
||||||
@@ -9,24 +10,26 @@ import { getActiveInstance } from '../../config/instances';
|
|||||||
|
|
||||||
interface NavDef {
|
interface NavDef {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
labelKey: string;
|
||||||
icon: IconName;
|
icon: IconName;
|
||||||
end?: boolean;
|
end?: boolean;
|
||||||
|
/** Hide this item unless the user holds the permission. */
|
||||||
|
perm?: Permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAIN_NAV: NavDef[] = [
|
const MAIN_NAV: NavDef[] = [
|
||||||
{ to: '/', label: 'Home', icon: 'house', end: true },
|
{ to: '/library', labelKey: 'nav.library', icon: 'vinyl-record' },
|
||||||
{ to: '/library', label: 'Library', icon: 'vinyl-record' },
|
{ to: '/discover', labelKey: 'nav.search', icon: 'magnifying-glass', perm: 'download' },
|
||||||
{ to: '/search', label: 'Search & download', icon: 'magnifying-glass' },
|
{ to: '/downloads', labelKey: 'nav.downloads', icon: 'arrow-circle-down', perm: 'download' },
|
||||||
{ to: '/downloads', label: 'Downloads', icon: 'arrow-circle-down' },
|
{ to: '/upload', labelKey: 'nav.upload', icon: 'upload-simple', perm: 'upload' },
|
||||||
{ to: '/storage', label: 'Storage', icon: 'hard-drives' },
|
{ to: '/storage', labelKey: 'nav.storage', icon: 'hard-drives' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CONN_CLASS: Record<string, { cls: string; txt: string }> = {
|
const CONN_KEY: Record<string, { cls: string; txtKey: string }> = {
|
||||||
connected: { cls: 'online', txt: 'Connected' },
|
connected: { cls: 'online', txtKey: 'conn.connected' },
|
||||||
connecting: { cls: 'syncing', txt: 'Connecting…' },
|
connecting: { cls: 'syncing', txtKey: 'conn.connecting' },
|
||||||
disconnected: { cls: 'offline', txt: 'Offline' },
|
disconnected: { cls: 'offline', txtKey: 'conn.disconnected' },
|
||||||
error: { cls: 'error', txt: 'Unreachable' },
|
error: { cls: 'error', txtKey: 'conn.error' },
|
||||||
};
|
};
|
||||||
|
|
||||||
function navClass({ isActive }: { isActive: boolean }) {
|
function navClass({ isActive }: { isActive: boolean }) {
|
||||||
@@ -34,14 +37,15 @@ function navClass({ isActive }: { isActive: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, isAdmin } = usePermissions();
|
const { user, isAdmin, hasPermission } = usePermissions();
|
||||||
const status = useConnectionStatus();
|
const status = useConnectionStatus();
|
||||||
const { data: playlists } = useGetPlaylistsQuery();
|
const { data: playlists } = useGetPlaylistsQuery();
|
||||||
const instance = getActiveInstance();
|
const instance = getActiveInstance();
|
||||||
|
|
||||||
const conn = CONN_CLASS[status] ?? CONN_CLASS.connecting;
|
const conn = CONN_KEY[status] ?? CONN_KEY.connecting;
|
||||||
const online = status === 'connected';
|
const online = status === 'connected';
|
||||||
|
|
||||||
const handleLogout = (e: React.MouseEvent) => {
|
const handleLogout = (e: React.MouseEvent) => {
|
||||||
@@ -59,20 +63,24 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb-sec">
|
<div className="sb-sec">
|
||||||
{MAIN_NAV.map(({ to, label, icon, end }) => (
|
{MAIN_NAV.filter((n) => !n.perm || hasPermission(n.perm)).map(
|
||||||
<NavLink key={to} to={to} end={end} className={navClass}>
|
({ to, labelKey, icon, end }) => (
|
||||||
<Icon name={icon} />
|
<NavLink key={to} to={to} end={end} className={navClass}>
|
||||||
<span>{label}</span>
|
<Icon name={icon} />
|
||||||
</NavLink>
|
<span>{t(labelKey)}</span>
|
||||||
))}
|
</NavLink>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb-sec">
|
<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) => (
|
{(playlists?.items ?? []).map((pl) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={pl.id}
|
key={pl.id}
|
||||||
to={`/library/playlists/${pl.id}`}
|
to={`/playlists/${pl.id}`}
|
||||||
className={navClass}
|
className={navClass}
|
||||||
>
|
>
|
||||||
<Icon name="playlist" />
|
<Icon name="playlist" />
|
||||||
@@ -82,30 +90,30 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pl-item"
|
className="pl-item"
|
||||||
onClick={() => void navigate('/library')}
|
onClick={() => void navigate('/playlists')}
|
||||||
>
|
>
|
||||||
<Icon name="plus" />
|
<Icon name="plus" />
|
||||||
<span className="pl-name">New playlist</span>
|
<span className="pl-name">{t('nav.newPlaylist')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<div className="sb-sec">
|
<div className="sb-sec">
|
||||||
<span className="msk-label">Administration</span>
|
<span className="msk-label">{t('nav.administration')}</span>
|
||||||
<NavLink to="/admin" className={navClass}>
|
<NavLink to="/admin" className={navClass}>
|
||||||
<Icon name="shield-check" />
|
<Icon name="shield-check" />
|
||||||
<span>Admin</span>
|
<span>{t('nav.admin')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink to="/settings" className={navClass}>
|
<NavLink to="/settings" className={navClass}>
|
||||||
<Icon name="gear-six" />
|
<Icon name="gear-six" />
|
||||||
<span>Settings</span>
|
<span>{t('nav.settings')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="sb-sec">
|
<div className="sb-sec">
|
||||||
<NavLink to="/settings" className={navClass}>
|
<NavLink to="/settings" className={navClass}>
|
||||||
<Icon name="gear-six" />
|
<Icon name="gear-six" />
|
||||||
<span>Settings</span>
|
<span>{t('nav.settings')}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -116,10 +124,10 @@ export function Sidebar() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`conn ${conn.cls}`}
|
className={`conn ${conn.cls}`}
|
||||||
onClick={() => void navigate('/connect')}
|
onClick={() => void navigate('/connect')}
|
||||||
title="Connection — manage instances"
|
title={t('conn.manage')}
|
||||||
>
|
>
|
||||||
<span className="led" />
|
<span className="led" />
|
||||||
{conn.txt}
|
{t(conn.txtKey)}
|
||||||
</button>
|
</button>
|
||||||
{user && (
|
{user && (
|
||||||
<button
|
<button
|
||||||
@@ -133,10 +141,14 @@ export function Sidebar() {
|
|||||||
<div className="user-meta">
|
<div className="user-meta">
|
||||||
<div className="nm">{user.username}</div>
|
<div className="nm">{user.username}</div>
|
||||||
<div className="rl">
|
<div className="rl">
|
||||||
{user.role} · {online ? 'online' : 'offline'}
|
{user.role} · {online ? t('user.online') : t('user.offline')}
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<Icon name="sign-out" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
@@ -9,59 +10,72 @@ import {
|
|||||||
setVolume,
|
setVolume,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} from '../../store/slices/player';
|
} from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
import { useAudioPlayer } from '../../hooks/useAudioPlayer';
|
||||||
|
import { useStreamCached } from '../../hooks/useStreamCached';
|
||||||
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function PersistentPlayer() {
|
export function PersistentPlayer() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { seek, playNext, playPrev } = useAudioPlayer();
|
const { seek, playNext, playPrev } = useAudioPlayer();
|
||||||
const player = useAppSelector((s) => s.player);
|
const player = useAppSelector((s) => s.player);
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const currentEntry = queue.entries[queue.currentIndex];
|
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) {
|
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 artUrl =
|
||||||
const seedLabel = currentEntry?.albumTitle ?? currentEntry?.title ?? '';
|
getCoverUrl(currentEntry?.albumArtUrl) ??
|
||||||
// Streaming is the web default; local playback is a mobile-client concern.
|
(token && current?.hasCover
|
||||||
const onStream = true;
|
? getTrackCoverUrl(current.trackId, token, true)
|
||||||
|
: undefined);
|
||||||
|
const seedLabel = current?.albumTitle ?? current?.title ?? '';
|
||||||
|
const onStream = !cached;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="player">
|
<div className="player">
|
||||||
{/* now-playing identity */}
|
|
||||||
<div
|
<div
|
||||||
className="pl-now"
|
className="pl-now"
|
||||||
onClick={() => dispatch(toggleNowPlaying())}
|
onClick={() =>
|
||||||
style={{ cursor: 'pointer' }}
|
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} />
|
<ArtTile seed={seedLabel} size={54} label={seedLabel} src={artUrl} />
|
||||||
<div className="pl-now-tt">
|
<div className="pl-now-tt">
|
||||||
<div className="t">{currentEntry?.title ?? '—'}</div>
|
<div className="t">{current?.title ?? '—'}</div>
|
||||||
<div className="a">{currentEntry?.artistName ?? ''}</div>
|
<div className="a">{current?.artistName ?? ''}</div>
|
||||||
<div
|
<div
|
||||||
className="pl-srcbadge"
|
className="pl-srcbadge"
|
||||||
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
style={{ color: onStream ? 'var(--fg-3)' : 'var(--lime)' }}
|
||||||
>
|
>
|
||||||
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
<Icon name={onStream ? 'cloud' : 'check-circle'} fill={!onStream} />
|
||||||
{onStream ? 'Streaming · 320 kbps' : 'Local · FLAC'}
|
{onStream ? t('player.streaming') : t('player.local')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* transport + scrubber */}
|
|
||||||
<div className="pl-center">
|
<div className="pl-center">
|
||||||
<div className="pl-transport">
|
<div className="pl-transport">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
|
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
|
||||||
onClick={() => dispatch(toggleShuffle())}
|
onClick={() => dispatch(toggleShuffle())}
|
||||||
title="Shuffle"
|
title={t('player.shuffle')}
|
||||||
>
|
>
|
||||||
<Icon name="shuffle" />
|
<Icon name="shuffle" />
|
||||||
</button>
|
</button>
|
||||||
@@ -69,7 +83,7 @@ export function PersistentPlayer() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="pl-tbtn"
|
className="pl-tbtn"
|
||||||
onClick={playPrev}
|
onClick={playPrev}
|
||||||
title="Previous"
|
title={t('player.previous')}
|
||||||
>
|
>
|
||||||
<Icon name="skip-back" fill />
|
<Icon name="skip-back" fill />
|
||||||
</button>
|
</button>
|
||||||
@@ -79,7 +93,7 @@ export function PersistentPlayer() {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
player.isPlaying ? dispatch(pause()) : dispatch(resume())
|
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 />
|
<Icon name={player.isPlaying ? 'pause' : 'play'} fill />
|
||||||
</button>
|
</button>
|
||||||
@@ -87,7 +101,7 @@ export function PersistentPlayer() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="pl-tbtn"
|
className="pl-tbtn"
|
||||||
onClick={playNext}
|
onClick={playNext}
|
||||||
title="Next"
|
title={t('player.next')}
|
||||||
>
|
>
|
||||||
<Icon name="skip-forward" fill />
|
<Icon name="skip-forward" fill />
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +119,7 @@ export function PersistentPlayer() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
title={`Repeat: ${player.repeat}`}
|
title={t('player.repeat', { mode: player.repeat })}
|
||||||
>
|
>
|
||||||
<Icon name="repeat" />
|
<Icon name="repeat" />
|
||||||
</button>
|
</button>
|
||||||
@@ -121,7 +135,7 @@ export function PersistentPlayer() {
|
|||||||
step={1}
|
step={1}
|
||||||
value={[player.position]}
|
value={[player.position]}
|
||||||
onValueChange={([v]) => seek(v)}
|
onValueChange={([v]) => seek(v)}
|
||||||
aria-label="Seek"
|
aria-label={t('player.play')}
|
||||||
/>
|
/>
|
||||||
<span className="pl-time">
|
<span className="pl-time">
|
||||||
{formatDuration(player.duration * 1000)}
|
{formatDuration(player.duration * 1000)}
|
||||||
@@ -129,13 +143,12 @@ export function PersistentPlayer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* volume + queue */}
|
|
||||||
<div className="pl-right">
|
<div className="pl-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="pl-tbtn"
|
className="pl-tbtn"
|
||||||
onClick={() => dispatch(toggleMute())}
|
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'} />
|
<Icon name={player.muted ? 'speaker-x' : 'speaker-high'} />
|
||||||
</button>
|
</button>
|
||||||
@@ -154,7 +167,7 @@ export function PersistentPlayer() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
|
className={`iconbtn sm${player.isQueueOpen ? ' on' : ''}`}
|
||||||
onClick={() => dispatch(toggleQueue())}
|
onClick={() => dispatch(toggleQueue())}
|
||||||
title="Play queue"
|
title={t('player.queue')}
|
||||||
>
|
>
|
||||||
<Icon name="queue" />
|
<Icon name="queue" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 { Icon } from '../common/Icon';
|
||||||
import { ArtTile } from '../common/ArtTile';
|
import { ArtTile } from '../common/ArtTile';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
@@ -6,16 +7,21 @@ import {
|
|||||||
goToIndex,
|
goToIndex,
|
||||||
removeFromQueue,
|
removeFromQueue,
|
||||||
clearQueue,
|
clearQueue,
|
||||||
|
type QueueEntry,
|
||||||
} from '../../store/slices/queue';
|
} from '../../store/slices/queue';
|
||||||
import { toggleQueue } from '../../store/slices/player';
|
import { toggleQueue } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
|
import { useResolvedQueueEntry } from '../../hooks/useResolvedQueueEntry';
|
||||||
|
|
||||||
export function QueuePanel() {
|
export function QueuePanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const queue = useAppSelector((s) => s.queue);
|
const queue = useAppSelector((s) => s.queue);
|
||||||
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
const isOpen = useAppSelector((s) => s.player.isQueueOpen);
|
||||||
|
|
||||||
const now =
|
const nowEntry =
|
||||||
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
queue.currentIndex >= 0 ? queue.entries[queue.currentIndex] : undefined;
|
||||||
|
const now = useResolvedQueueEntry(nowEntry);
|
||||||
const upNext = queue.entries
|
const upNext = queue.entries
|
||||||
.map((entry, index) => ({ entry, index }))
|
.map((entry, index) => ({ entry, index }))
|
||||||
.filter(({ index }) => index > queue.currentIndex);
|
.filter(({ index }) => index > queue.currentIndex);
|
||||||
@@ -27,13 +33,13 @@ export function QueuePanel() {
|
|||||||
<div className="qd-inner">
|
<div className="qd-inner">
|
||||||
<div className="qd-head">
|
<div className="qd-head">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<h3>Play queue</h3>
|
<h3>{t('queue.title')}</h3>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="iconbtn sm"
|
className="iconbtn sm"
|
||||||
onClick={() => dispatch(clearQueue())}
|
onClick={() => dispatch(clearQueue())}
|
||||||
title="Clear queue"
|
title={t('queue.clear')}
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
@@ -41,7 +47,7 @@ export function QueuePanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="iconbtn sm"
|
className="iconbtn sm"
|
||||||
onClick={() => dispatch(toggleQueue())}
|
onClick={() => dispatch(toggleQueue())}
|
||||||
title="Close"
|
title={t('queue.close')}
|
||||||
>
|
>
|
||||||
<Icon name="x" />
|
<Icon name="x" />
|
||||||
</button>
|
</button>
|
||||||
@@ -53,10 +59,10 @@ export function QueuePanel() {
|
|||||||
/>
|
/>
|
||||||
{isRadio ? (
|
{isRadio ? (
|
||||||
<span style={{ color: 'var(--lime)' }}>
|
<span style={{ color: 'var(--lime)' }}>
|
||||||
Radio · {sourceLabel}
|
{t('queue.radio', { source: sourceLabel })}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>From {sourceLabel}</span>
|
<span>{t('queue.from', { source: sourceLabel })}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +74,7 @@ export function QueuePanel() {
|
|||||||
className="msk-label"
|
className="msk-label"
|
||||||
style={{ display: 'block', marginBottom: 8 }}
|
style={{ display: 'block', marginBottom: 8 }}
|
||||||
>
|
>
|
||||||
Now playing
|
{t('queue.nowPlaying')}
|
||||||
</span>
|
</span>
|
||||||
<div className="qd-now">
|
<div className="qd-now">
|
||||||
<ArtTile
|
<ArtTile
|
||||||
@@ -80,7 +86,14 @@ export function QueuePanel() {
|
|||||||
<div className="t">{now.title}</div>
|
<div className="t">{now.title}</div>
|
||||||
<div className="r">{now.artistName}</div>
|
<div className="r">{now.artistName}</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{isRadio && (
|
{isRadio && (
|
||||||
@@ -94,14 +107,13 @@ export function QueuePanel() {
|
|||||||
color: 'var(--fg-1)',
|
color: 'var(--fg-1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Radio active
|
{t('queue.radioActive')}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
<Badge variant="neutral">∞ mixing</Badge>
|
<Badge variant="neutral">{t('queue.mixing')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{/* exploration balance — stub under the future ML contract */}
|
|
||||||
<div className="expl">
|
<div className="expl">
|
||||||
<span className="lab">Familiar</span>
|
<span className="lab">{t('queue.familiar')}</span>
|
||||||
<Slider
|
<Slider
|
||||||
className="expl-slider"
|
className="expl-slider"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -110,7 +122,7 @@ export function QueuePanel() {
|
|||||||
defaultValue={[42]}
|
defaultValue={[42]}
|
||||||
aria-label="Exploration"
|
aria-label="Exploration"
|
||||||
/>
|
/>
|
||||||
<span className="lab">New</span>
|
<span className="lab">{t('queue.new')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -119,51 +131,71 @@ export function QueuePanel() {
|
|||||||
className="msk-label"
|
className="msk-label"
|
||||||
style={{ display: 'block', margin: '4px 0 8px' }}
|
style={{ display: 'block', margin: '4px 0 8px' }}
|
||||||
>
|
>
|
||||||
Next up
|
{t('queue.nextUp')}
|
||||||
</span>
|
</span>
|
||||||
{upNext.length === 0 ? (
|
{upNext.length === 0 ? (
|
||||||
<div className="qd-empty">Nothing queued next</div>
|
<div className="qd-empty">{t('queue.nothingNext')}</div>
|
||||||
) : (
|
) : (
|
||||||
upNext.map(({ entry, index }) => (
|
upNext.map(({ entry, index }) => (
|
||||||
<div
|
<QueueRow
|
||||||
key={`${entry.trackId}-${index}`}
|
key={`${entry.trackId}-${index}`}
|
||||||
className="qrow"
|
entry={entry}
|
||||||
onDoubleClick={() => dispatch(goToIndex(index))}
|
onPlay={() => dispatch(goToIndex(index))}
|
||||||
title="Double-click to play"
|
onRemove={() => dispatch(removeFromQueue(index))}
|
||||||
>
|
/>
|
||||||
<span className="grip">
|
|
||||||
<Icon name="dots-six-vertical" />
|
|
||||||
</span>
|
|
||||||
<ArtTile
|
|
||||||
seed={entry.albumTitle}
|
|
||||||
size={36}
|
|
||||||
label={entry.albumTitle}
|
|
||||||
/>
|
|
||||||
<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 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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,4 +1,4 @@
|
|||||||
import { Badge, Tooltip } from 'modern-sk';
|
import { Badge, Tooltip } from '@olly/modern-sk';
|
||||||
import type { TrackAvailability } from '../../api/types';
|
import type { TrackAvailability } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,12 @@ import {
|
|||||||
MenuItem,
|
MenuItem,
|
||||||
MenuSeparator,
|
MenuSeparator,
|
||||||
IconButton,
|
IconButton,
|
||||||
} from 'modern-sk';
|
} from '@olly/modern-sk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
import { addToQueue, addNextInQueue } from '../../store/slices/queue';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
|
import { openTrackInfo } from '../../store/slices/ui';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -26,6 +28,7 @@ export function TrackContextMenu({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onDownload,
|
onDownload,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
@@ -40,7 +43,11 @@ export function TrackContextMenu({
|
|||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuTrigger asChild>
|
<MenuTrigger asChild>
|
||||||
<IconButton variant="ghost" size="sm" aria-label="Track options">
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('track.menu.options')}
|
||||||
|
>
|
||||||
⋯
|
⋯
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</MenuTrigger>
|
</MenuTrigger>
|
||||||
@@ -50,39 +57,51 @@ export function TrackContextMenu({
|
|||||||
dispatch(play(track.id));
|
dispatch(play(track.id));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Play now
|
{t('track.menu.playNow')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
dispatch(addNextInQueue(entry));
|
dispatch(addNextInQueue(entry));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Play next
|
{t('track.menu.playNext')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
dispatch(addToQueue(entry));
|
dispatch(addToQueue(entry));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add to queue
|
{t('track.menu.addToQueue')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem
|
||||||
|
onSelect={() => {
|
||||||
|
dispatch(openTrackInfo(track.id));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('track.menu.info')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{onAddToPlaylist && (
|
{onAddToPlaylist && (
|
||||||
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
<MenuItem onSelect={() => onAddToPlaylist(track)}>
|
||||||
Add to playlist…
|
{t('track.menu.addToPlaylist')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
{onEditMetadata && (
|
{onEditMetadata && (
|
||||||
<MenuItem onSelect={() => onEditMetadata(track)}>
|
<MenuItem onSelect={() => onEditMetadata(track)}>
|
||||||
Edit metadata
|
{t('track.menu.editMetadata')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{onDownload && (
|
{onDownload && (
|
||||||
<MenuItem onSelect={() => onDownload(track)}>Download</MenuItem>
|
<MenuItem onSelect={() => onDownload(track)}>
|
||||||
|
{t('track.menu.download')}
|
||||||
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
<MenuItem onSelect={() => onDelete(track)}>Delete</MenuItem>
|
<MenuItem onSelect={() => onDelete(track)}>
|
||||||
|
{t('track.menu.delete')}
|
||||||
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
</MenuContent>
|
</MenuContent>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Row } from 'modern-sk';
|
import { Row } from '@olly/modern-sk';
|
||||||
import { TrackContextMenu } from './TrackContextMenu';
|
import { TrackContextMenu } from './TrackContextMenu';
|
||||||
import { AvailabilityBadge } from './AvailabilityBadge';
|
import { AvailabilityBadge } from './AvailabilityBadge';
|
||||||
|
import { MetadataStatusBadge } from './MetadataStatusBadge';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
import { play } from '../../store/slices/player';
|
import { play } from '../../store/slices/player';
|
||||||
import type { Track } from '../../api/types';
|
import type { Track } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl, getTrackCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
track: Track;
|
track: Track;
|
||||||
@@ -27,8 +28,13 @@ export function TrackRow({
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
const currentTrackId = useAppSelector((s) => s.player.currentTrackId);
|
||||||
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
const isPlaying = useAppSelector((s) => s.player.isPlaying);
|
||||||
|
const token = useAppSelector((s) => s.auth.accessToken);
|
||||||
const isActive = currentTrackId === track.id;
|
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 (
|
return (
|
||||||
<Row
|
<Row
|
||||||
@@ -95,7 +101,13 @@ export function TrackRow({
|
|||||||
{showAlbum && ` · ${track.albumTitle}`}
|
{showAlbum && ` · ${track.albumTitle}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AvailabilityBadge availability={track.availability} />
|
<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' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
+40
-1
@@ -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 =
|
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
@@ -29,8 +29,13 @@ const ACTIVE_KEY = 'mcma:activeInstance';
|
|||||||
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
const LEGACY_URL_KEY = 'mcma_api_base_url';
|
||||||
const LEGACY_AUTH_KEY = 'mcma_auth';
|
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 {
|
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. */
|
/** 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;
|
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. */
|
/** Remove a backend and wipe every scoped key it owns. */
|
||||||
export function removeInstance(id: string): void {
|
export function removeInstance(id: string): void {
|
||||||
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
writeRegistry(readRegistry().filter((i) => i.id !== id));
|
||||||
|
|||||||
Vendored
+10
@@ -1,7 +1,17 @@
|
|||||||
/// <reference types="@rsbuild/core/types" />
|
/// <reference types="@rsbuild/core/types" />
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PUBLIC_API_BASE_URL?: string;
|
readonly PUBLIC_API_BASE_URL?: string;
|
||||||
|
readonly PUBLIC_ENABLE_REGISTRATION?: string;
|
||||||
}
|
}
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div
|
||||||
<Window title="Admin">
|
style={{
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
padding: '1.5rem',
|
||||||
</Window>
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="page-title">{t('pages.admin')}</h1>
|
||||||
|
<SubNav items={items} />
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
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 {
|
import {
|
||||||
useGetAlbumQuery,
|
useGetAlbumQuery,
|
||||||
useGetAlbumTracksQuery,
|
useGetAlbumTracksQuery,
|
||||||
@@ -14,6 +15,7 @@ import { formatDuration } from '../../lib/format';
|
|||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
|
|
||||||
export function AlbumDetailPage() {
|
export function AlbumDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { albumId } = useParams<{ albumId: string }>();
|
const { albumId } = useParams<{ albumId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -32,7 +34,7 @@ export function AlbumDetailPage() {
|
|||||||
if (albumQuery.isError) {
|
if (albumQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Failed to load album"
|
message={t('album.error')}
|
||||||
onRetry={() => albumQuery.refetch()}
|
onRetry={() => albumQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -63,7 +65,6 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* header */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
@@ -78,7 +79,7 @@ export function AlbumDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
aria-label="Back"
|
aria-label={t('common.back')}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -125,7 +126,7 @@ export function AlbumDetailPage() {
|
|||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Album
|
{t('album.type')}
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
@@ -146,7 +147,7 @@ export function AlbumDetailPage() {
|
|||||||
{album?.artistName}
|
{album?.artistName}
|
||||||
{album?.year && ` · ${album.year}`}
|
{album?.year && ` · ${album.year}`}
|
||||||
{album &&
|
{album &&
|
||||||
` · ${album.trackCount} tracks · ${formatDuration(album.totalDurationMs)}`}
|
` · ${album.trackCount} · ${formatDuration(album.totalDurationMs)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,16 +156,15 @@ export function AlbumDetailPage() {
|
|||||||
onClick={handlePlayAll}
|
onClick={handlePlayAll}
|
||||||
disabled={!tracks.length}
|
disabled={!tracks.length}
|
||||||
>
|
>
|
||||||
▶ Play
|
{t('album.play')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tracks */}
|
|
||||||
<ScrollArea style={{ flex: 1 }}>
|
<ScrollArea style={{ flex: 1 }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
||||||
{tracksQuery.isError && (
|
{tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Failed to load tracks"
|
message={t('album.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -173,8 +173,8 @@ export function AlbumDetailPage() {
|
|||||||
tracks.length === 0 && (
|
tracks.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title="No tracks"
|
title={t('album.empty.title')}
|
||||||
description="This album has no tracks."
|
description={t('album.empty.description')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{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')} />;
|
||||||
|
}
|
||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -1,64 +1,273 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
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 { Icon } from '../../components/common/Icon';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||||
|
import { useConnectionStatus } from '../../hooks/useConnectionStatus';
|
||||||
import { setTokens, setUser } from '../../store/slices/auth';
|
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 {
|
import {
|
||||||
listInstances,
|
listInstances,
|
||||||
getActiveInstanceId,
|
getActiveInstanceId,
|
||||||
setActiveInstanceId,
|
setActiveInstanceId,
|
||||||
removeInstance,
|
removeInstance,
|
||||||
|
clearInstanceAuth,
|
||||||
|
upsertInstance,
|
||||||
|
type Instance,
|
||||||
} from '../../config/instances';
|
} 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() {
|
export function ConnectPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Re-read on each render trigger; instance ops below force a remount via state.
|
|
||||||
const [rev, setRev] = useState(0);
|
const [rev, setRev] = useState(0);
|
||||||
const instances = listInstances();
|
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 [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = 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
|
const [login, { isLoading: isLoggingIn }] = useLoginMutation();
|
||||||
// from that instance's namespaced storage (its own session, prefs, cache).
|
const [register, { isLoading: isRegistering }] = useRegisterMutation();
|
||||||
const switchTo = (id: string) => {
|
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);
|
setActiveInstanceId(id);
|
||||||
window.location.assign('/');
|
window.location.assign('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
const forget = (id: string) => {
|
const handleAdd = (e: React.FormEvent) => {
|
||||||
removeInstance(id);
|
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);
|
setRev((r) => r + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// STUB: no backend yet. Register the instance, then fake a session so the rest
|
const handleLogout = (id: string) => {
|
||||||
// of the app is reachable. Replace with the real useLoginMutation() flow later.
|
clearInstanceAuth(id);
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
setRev((r) => r + 1);
|
||||||
e.preventDefault();
|
};
|
||||||
setApiBaseUrl(apiUrl); // upsert + activate this backend
|
|
||||||
|
|
||||||
const fakeUser: User = {
|
const handleRemove = (id: string) => {
|
||||||
id: 'dev-user',
|
removeInstance(id);
|
||||||
username: username || 'dev',
|
if (selectedId === id) setSelectedId(getActiveInstanceId());
|
||||||
role: 'admin',
|
setRev((r) => r + 1);
|
||||||
createdAt: new Date().toISOString(),
|
};
|
||||||
};
|
|
||||||
dispatch(
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
setTokens({
|
e.preventDefault();
|
||||||
accessToken: 'dev-token',
|
if (!selectedInstance) return;
|
||||||
refreshToken: 'dev-refresh',
|
setError(null);
|
||||||
expiresIn: 3600,
|
|
||||||
}),
|
try {
|
||||||
);
|
const action =
|
||||||
dispatch(setUser(fakeUser));
|
mode === 'register'
|
||||||
void navigate('/');
|
? 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 = {
|
const labelStyle: React.CSSProperties = {
|
||||||
@@ -103,146 +312,172 @@ export function ConnectPage() {
|
|||||||
<Icon name="vinyl-record" fill /> MCMA
|
<Icon name="vinyl-record" fill /> MCMA
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{instances.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: '1.25rem 1.5rem',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: '0.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
|
||||||
Saved instances
|
|
||||||
</span>
|
|
||||||
{instances.map((inst) => (
|
|
||||||
<div
|
|
||||||
key={inst.id}
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.625rem',
|
|
||||||
padding: '0.375rem 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,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => switchTo(inst.id)}
|
|
||||||
>
|
|
||||||
Use
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="iconbtn sm"
|
|
||||||
onClick={() => forget(inst.id)}
|
|
||||||
title="Forget this instance"
|
|
||||||
>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<form
|
<div
|
||||||
onSubmit={handleSubmit}
|
|
||||||
style={{
|
style={{
|
||||||
|
padding: '1.25rem 1.5rem',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: '1rem',
|
gap: '0.5rem',
|
||||||
padding: '1.5rem',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="msk-label">Connect to a backend</span>
|
{instances.length > 0 && (
|
||||||
<div>
|
<>
|
||||||
<label style={labelStyle}>Server URL</label>
|
<span className="msk-label" style={{ marginBottom: '0.25rem' }}>
|
||||||
<TextField
|
{t('connect.domains.title')}
|
||||||
value={apiUrl}
|
</span>
|
||||||
onChange={(e) => setApiUrl(e.target.value)}
|
{instances.map((inst) => (
|
||||||
placeholder="https://your-server.example.com/api/v1"
|
<InstanceRow
|
||||||
required
|
key={inst.id}
|
||||||
/>
|
inst={inst}
|
||||||
</div>
|
selected={inst.id === selectedId}
|
||||||
<div>
|
onSelect={() => selectInstance(inst.id)}
|
||||||
<label style={labelStyle}>Username</label>
|
onLogout={() => handleLogout(inst.id)}
|
||||||
<TextField
|
onRemove={() => handleRemove(inst.id)}
|
||||||
value={username}
|
/>
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
))}
|
||||||
placeholder="username"
|
</>
|
||||||
autoComplete="username"
|
)}
|
||||||
required
|
<form
|
||||||
/>
|
onSubmit={handleAdd}
|
||||||
</div>
|
style={{
|
||||||
<div>
|
display: 'flex',
|
||||||
<label style={labelStyle}>Password</label>
|
gap: '0.5rem',
|
||||||
<TextField
|
marginTop: instances.length > 0 ? '0.5rem' : 0,
|
||||||
type="password"
|
}}
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Callout variant="warning">
|
|
||||||
Stub mode — backend not wired. Connect signs in with a fake admin
|
|
||||||
session, scoped to this instance.
|
|
||||||
</Callout>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
style={{ marginTop: '0.5rem' }}
|
|
||||||
>
|
>
|
||||||
Connect
|
{instanceAddShown ? (
|
||||||
</Button>
|
<>
|
||||||
</form>
|
<TextField
|
||||||
|
value={addUrl}
|
||||||
|
onChange={(e) => setAddUrl(e.target.value)}
|
||||||
|
placeholder={t('connect.domains.addPlaceholder')}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<IconButton type="submit" variant="primary">
|
||||||
|
<Icon name="plus" />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => setInstanceAddShown(true)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Icon name="plus" /> {t('connect.domains.addButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{selectedInstance && (
|
||||||
|
<Card>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}>{t('connect.login.username')}</label>
|
||||||
|
<TextField
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="username"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('connect.login.password')}</label>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="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>
|
||||||
|
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{ marginTop: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
</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() {
|
export function DownloadsManagerPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<Window title="Downloads">
|
<Window title={t('pages.downloads')}>
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
TabsList,
|
TabsList,
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
SearchField,
|
SearchField,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
} from 'modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
useGetAlbumsQuery,
|
useGetAlbumsQuery,
|
||||||
@@ -24,6 +25,7 @@ import { getCoverUrl } from '../../api/endpoints/streaming';
|
|||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [tab, setTab] = useState('tracks');
|
const [tab, setTab] = useState('tracks');
|
||||||
@@ -45,7 +47,7 @@ export function LibraryPage() {
|
|||||||
albumArtUrl: t.albumArtUrl,
|
albumArtUrl: t.albumArtUrl,
|
||||||
})),
|
})),
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
sourceName: 'Library',
|
sourceName: t('library.title'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -63,13 +65,13 @@ export function LibraryPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
<h2 style={{ margin: 0, fontSize: '1.125rem', fontWeight: 700 }}>
|
||||||
Library
|
{t('library.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
<div style={{ flex: 1, maxWidth: '20rem' }}>
|
||||||
<SearchField
|
<SearchField
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Search library…"
|
placeholder={t('library.searchPlaceholder')}
|
||||||
icon="⌕"
|
icon="⌕"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,9 +96,9 @@ export function LibraryPage() {
|
|||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
items={[
|
items={[
|
||||||
{ value: 'tracks', label: 'Tracks' },
|
{ value: 'tracks', label: t('library.tabs.tracks') },
|
||||||
{ value: 'albums', label: 'Albums' },
|
{ value: 'albums', label: t('library.tabs.albums') },
|
||||||
{ value: 'artists', label: 'Artists' },
|
{ value: 'artists', label: t('library.tabs.artists') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,8 +112,8 @@ export function LibraryPage() {
|
|||||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title="No tracks"
|
title={t('library.empty.tracks.title')}
|
||||||
description="Your library is empty. Start by downloading some music."
|
description={t('library.empty.tracks.description')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data &&
|
{tracksQuery.data &&
|
||||||
@@ -140,7 +142,7 @@ export function LibraryPage() {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
▶ Play all ({data.total})
|
{t('library.playAll', { count: data.total })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{data.items.map((track, i) => (
|
{data.items.map((track, i) => (
|
||||||
@@ -166,8 +168,8 @@ export function LibraryPage() {
|
|||||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="💿"
|
icon="💿"
|
||||||
title="No albums"
|
title={t('library.empty.albums.title')}
|
||||||
description="No albums in library."
|
description={t('library.empty.albums.description')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && (
|
{albumsQuery.data && (
|
||||||
@@ -183,7 +185,7 @@ export function LibraryPage() {
|
|||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
onClick={() => void navigate(`/library/albums/${album.id}`)}
|
onClick={() => void navigate(`/albums/${album.id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -200,8 +202,8 @@ export function LibraryPage() {
|
|||||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎤"
|
icon="🎤"
|
||||||
title="No artists"
|
title={t('library.empty.artists.title')}
|
||||||
description="No artists in library."
|
description={t('library.empty.artists.description')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && (
|
{artistsQuery.data && (
|
||||||
@@ -219,6 +221,7 @@ export function LibraryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const artUrl = getCoverUrl(album.artUrl);
|
const artUrl = getCoverUrl(album.artUrl);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -282,7 +285,10 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
{album.artistName}
|
{album.artistName}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.6875rem', color: 'var(--color-text-3)' }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -290,6 +296,7 @@ function AlbumCard({ album, onClick }: { album: Album; onClick: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ArtistRow({ artist }: { artist: Artist }) {
|
function ArtistRow({ artist }: { artist: Artist }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -319,7 +326,10 @@ function ArtistRow({ artist }: { artist: Artist }) {
|
|||||||
{artist.name}
|
{artist.name}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '0.75rem', color: 'var(--color-text-3)' }}>
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { 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 {
|
import {
|
||||||
useGetPlaylistQuery,
|
useGetPlaylistQuery,
|
||||||
useGetPlaylistTracksQuery,
|
useGetPlaylistTracksQuery,
|
||||||
@@ -13,6 +14,7 @@ import { setQueue } from '../../store/slices/queue';
|
|||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
|
|
||||||
export function PlaylistDetailPage() {
|
export function PlaylistDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { playlistId } = useParams<{ playlistId: string }>();
|
const { playlistId } = useParams<{ playlistId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
@@ -35,7 +37,7 @@ export function PlaylistDetailPage() {
|
|||||||
if (playlistQuery.isError) {
|
if (playlistQuery.isError) {
|
||||||
return (
|
return (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Failed to load playlist"
|
message={t('playlist.error')}
|
||||||
onRetry={() => playlistQuery.refetch()}
|
onRetry={() => playlistQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -79,7 +81,7 @@ export function PlaylistDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
aria-label="Back"
|
aria-label={t('common.back')}
|
||||||
>
|
>
|
||||||
←
|
←
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -93,7 +95,7 @@ export function PlaylistDetailPage() {
|
|||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Playlist
|
{t('playlist.type')}
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
|
style={{ margin: '0.25rem 0', fontSize: '1.5rem', fontWeight: 700 }}
|
||||||
@@ -119,7 +121,7 @@ export function PlaylistDetailPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playlist &&
|
{playlist &&
|
||||||
`${playlist.trackCount} tracks · ${formatDuration(playlist.totalDurationMs)}`}
|
`${playlist.trackCount} · ${formatDuration(playlist.totalDurationMs)}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -127,7 +129,7 @@ export function PlaylistDetailPage() {
|
|||||||
onClick={handlePlayAll}
|
onClick={handlePlayAll}
|
||||||
disabled={!tracks.length}
|
disabled={!tracks.length}
|
||||||
>
|
>
|
||||||
▶ Play
|
{t('playlist.play')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ export function PlaylistDetailPage() {
|
|||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
{tracksQuery.isLoading && <LoadingSkeleton rows={10} />}
|
||||||
{tracksQuery.isError && (
|
{tracksQuery.isError && (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Failed to load tracks"
|
message={t('playlist.tracksError')}
|
||||||
onRetry={() => tracksQuery.refetch()}
|
onRetry={() => tracksQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -144,8 +146,8 @@ export function PlaylistDetailPage() {
|
|||||||
tracks.length === 0 && (
|
tracks.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title="Empty playlist"
|
title={t('playlist.empty.title')}
|
||||||
description="This playlist has no tracks yet."
|
description={t('playlist.empty.description')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracks.map((track, i) => (
|
{tracks.map((track, i) => (
|
||||||
|
|||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -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() {
|
export function SearchDownloadPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<Window title="Search & Download">
|
<Window title={t('pages.search')}>
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div
|
||||||
<Window title="Settings">
|
style={{
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
padding: '1.5rem',
|
||||||
</Window>
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1 className="page-title">{t('pages.settings')}</h1>
|
||||||
|
<SubNav items={items} />
|
||||||
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')} />;
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Window } from 'modern-sk';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Window } from '@olly/modern-sk';
|
||||||
|
|
||||||
export function StoragePage() {
|
export function StoragePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '1.5rem' }}>
|
<div style={{ padding: '1.5rem' }}>
|
||||||
<Window title="Storage">
|
<Window title={t('pages.storage')}>
|
||||||
<p style={{ color: 'var(--color-text-2)' }}>Coming soon</p>
|
<p style={{ color: 'var(--color-text-2)' }}>{t('common.comingSoon')}</p>
|
||||||
</Window>
|
</Window>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ import { getApiBaseUrl } from '../config/runtime-config';
|
|||||||
|
|
||||||
type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
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');
|
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -13,7 +15,7 @@ export function useConnectionStatus() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStatus('connecting');
|
setStatus('connecting');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${getApiBaseUrl()}/health`, {
|
const res = await fetch(`${url}/health`, {
|
||||||
signal: AbortSignal.timeout(5000),
|
signal: AbortSignal.timeout(5000),
|
||||||
});
|
});
|
||||||
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
if (!cancelled) setStatus(res.ok ? 'connected' : 'error');
|
||||||
@@ -30,7 +32,7 @@ export function useConnectionStatus() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [url]);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useAppSelector } from './useAppDispatch';
|
import { useAppSelector } from './useAppDispatch';
|
||||||
|
|
||||||
type Permission =
|
export type Permission =
|
||||||
| 'download'
|
| 'download'
|
||||||
| 'upload'
|
| 'upload'
|
||||||
| 'admin'
|
| 'admin'
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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>;
|
||||||
@@ -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
@@ -1,14 +1,16 @@
|
|||||||
import 'modern-sk/styles.css';
|
import '@olly/modern-sk/styles.css';
|
||||||
import 'modern-sk/fonts.css';
|
import '@olly/modern-sk/fonts.css';
|
||||||
import './styles/global.css';
|
import './styles/global.css';
|
||||||
import './styles/shell.css';
|
import './styles/shell.css';
|
||||||
|
import './i18n';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router';
|
import { BrowserRouter } from 'react-router';
|
||||||
import { ThemeProvider, TooltipProvider } from 'modern-sk';
|
import { ThemeProvider, TooltipProvider } from '@olly/modern-sk';
|
||||||
import { store } from './store';
|
import { store } from './store';
|
||||||
import { AppRoutes } from './routes';
|
import { AppRoutes } from './routes';
|
||||||
|
import { registerServiceWorker } from './lib/sw';
|
||||||
|
|
||||||
// Import all endpoint injections to ensure they are registered
|
// Import all endpoint injections to ensure they are registered
|
||||||
import './api/endpoints/auth';
|
import './api/endpoints/auth';
|
||||||
@@ -20,6 +22,10 @@ import './api/endpoints/storage';
|
|||||||
import './api/endpoints/admin';
|
import './api/endpoints/admin';
|
||||||
import './api/endpoints/upload';
|
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');
|
const rootEl = document.getElementById('root');
|
||||||
if (rootEl) {
|
if (rootEl) {
|
||||||
// grained black-ish background + base text color from modern-sk
|
// grained black-ish background + base text color from modern-sk
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
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 {
|
export function formatCount(n: number): string {
|
||||||
if (n < 1000) return String(n);
|
if (n < 1000) return String(n);
|
||||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import { Navigate } from 'react-router';
|
import { Navigate } from 'react-router';
|
||||||
import { useAppSelector } from '../hooks/useAppDispatch';
|
import { useAppSelector } from '../hooks/useAppDispatch';
|
||||||
|
import { usePermissions, type Permission } from '../hooks/usePermissions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
requireAdmin?: boolean;
|
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 auth = useAppSelector((s) => s.auth);
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
if (!auth.accessToken || !auth.user) {
|
if (!auth.accessToken || !auth.user) {
|
||||||
return <Navigate to="/connect" replace />;
|
return <Navigate to="/connect" replace />;
|
||||||
@@ -17,5 +25,9 @@ export function ProtectedRoute({ children, requireAdmin = false }: Props) {
|
|||||||
return <Navigate to="/library" replace />;
|
return <Navigate to="/library" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requirePermission && !hasPermission(requirePermission)) {
|
||||||
|
return <Navigate to="/library" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-51
@@ -1,14 +1,38 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
import { Routes, Route, Navigate } from 'react-router';
|
import { Routes, Route, Navigate } from 'react-router';
|
||||||
import { AppShell } from '../components/layout/AppShell';
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
|
|
||||||
|
// Public (outside the shell)
|
||||||
import { ConnectPage } from '../features/connect/ConnectPage';
|
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 { LibraryPage } from '../features/library/LibraryPage';
|
||||||
import { AlbumDetailPage } from '../features/album-detail/AlbumDetailPage';
|
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 { 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(() =>
|
const SearchDownloadPage = lazy(() =>
|
||||||
import('../features/search-download/SearchDownloadPage').then((m) => ({
|
import('../features/search-download/SearchDownloadPage').then((m) => ({
|
||||||
default: m.SearchDownloadPage,
|
default: m.SearchDownloadPage,
|
||||||
@@ -19,30 +43,36 @@ const DownloadsManagerPage = lazy(() =>
|
|||||||
default: m.DownloadsManagerPage,
|
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(() =>
|
const StoragePage = lazy(() =>
|
||||||
import('../features/storage/StoragePage').then((m) => ({
|
import('../features/storage/StoragePage').then((m) => ({
|
||||||
default: m.StoragePage,
|
default: m.StoragePage,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const AdminPage = lazy(() =>
|
const StorageMaintenancePage = lazy(() =>
|
||||||
import('../features/admin/AdminPage').then((m) => ({ default: m.AdminPage })),
|
import('../features/storage/StorageMaintenancePage').then((m) => ({
|
||||||
);
|
default: m.StorageMaintenancePage,
|
||||||
const SettingsPage = lazy(() =>
|
|
||||||
import('../features/settings/SettingsPage').then((m) => ({
|
|
||||||
default: m.SettingsPage,
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
const QueuePage = lazy(() =>
|
||||||
const Fallback = () => (
|
import('../features/queue/QueuePage').then((m) => ({ default: m.QueuePage })),
|
||||||
<div style={{ padding: '2rem' }}>
|
|
||||||
<LoadingSkeleton />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public */}
|
||||||
<Route path="/connect" element={<ConnectPage />} />
|
<Route path="/connect" element={<ConnectPage />} />
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
{/* Authenticated shell */}
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
@@ -50,57 +80,85 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<HomePage />} />
|
<Route index element={<Navigate to="/library" replace />} />
|
||||||
|
|
||||||
|
{/* Library */}
|
||||||
<Route path="/library" element={<LibraryPage />} />
|
<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
|
<Route
|
||||||
path="/library/playlists/:playlistId"
|
path="/discover"
|
||||||
element={<PlaylistDetailPage />}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/search"
|
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Fallback />}>
|
<ProtectedRoute requirePermission="download">
|
||||||
<SearchDownloadPage />
|
<SearchDownloadPage />
|
||||||
</Suspense>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/downloads"
|
path="/downloads"
|
||||||
element={
|
element={
|
||||||
<Suspense fallback={<Fallback />}>
|
<ProtectedRoute requirePermission="download">
|
||||||
<DownloadsManagerPage />
|
<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>
|
</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>
|
||||||
<Route path="*" element={<Navigate to="/library" replace />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import authReducer from './slices/auth';
|
|||||||
import playerReducer from './slices/player';
|
import playerReducer from './slices/player';
|
||||||
import queueReducer from './slices/queue';
|
import queueReducer from './slices/queue';
|
||||||
import uiReducer from './slices/ui';
|
import uiReducer from './slices/ui';
|
||||||
|
import { loadPlayerState, loadQueueState, startPersistence } from './persist';
|
||||||
|
import { rehydrateApiCache, startApiPersistence } from './rtkqPersist';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -13,9 +15,23 @@ export const store = configureStore({
|
|||||||
queue: queueReducer,
|
queue: queueReducer,
|
||||||
ui: uiReducer,
|
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) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().concat(api.middleware),
|
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 RootState = ReturnType<typeof store.getState>;
|
||||||
export type AppDispatch = typeof store.dispatch;
|
export type AppDispatch = typeof store.dispatch;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
|
|||||||
action: PayloadAction<{
|
action: PayloadAction<{
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
expiresIn: number;
|
expiresIn?: number;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
state.accessToken = action.payload.accessToken;
|
state.accessToken = action.payload.accessToken;
|
||||||
state.refreshToken = action.payload.refreshToken;
|
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);
|
persistAuth(state);
|
||||||
},
|
},
|
||||||
setUser(state, action: PayloadAction<User>) {
|
setUser(state, action: PayloadAction<User>) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
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;
|
currentTrackId: string | null;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
position: number;
|
position: number;
|
||||||
@@ -11,11 +11,10 @@ interface PlayerState {
|
|||||||
muted: boolean;
|
muted: boolean;
|
||||||
repeat: RepeatMode;
|
repeat: RepeatMode;
|
||||||
shuffle: boolean;
|
shuffle: boolean;
|
||||||
isNowPlayingOpen: boolean;
|
|
||||||
isQueueOpen: boolean;
|
isQueueOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: PlayerState = {
|
export const playerInitialState: PlayerState = {
|
||||||
currentTrackId: null,
|
currentTrackId: null,
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
position: 0,
|
position: 0,
|
||||||
@@ -24,15 +23,12 @@ const initialState: PlayerState = {
|
|||||||
muted: false,
|
muted: false,
|
||||||
repeat: 'none',
|
repeat: 'none',
|
||||||
shuffle: false,
|
shuffle: false,
|
||||||
isNowPlayingOpen: false,
|
isQueueOpen: 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const playerSlice = createSlice({
|
export const playerSlice = createSlice({
|
||||||
name: 'player',
|
name: 'player',
|
||||||
initialState,
|
initialState: playerInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
play(state, action: PayloadAction<string>) {
|
play(state, action: PayloadAction<string>) {
|
||||||
state.currentTrackId = action.payload;
|
state.currentTrackId = action.payload;
|
||||||
@@ -68,9 +64,6 @@ export const playerSlice = createSlice({
|
|||||||
toggleShuffle(state) {
|
toggleShuffle(state) {
|
||||||
state.shuffle = !state.shuffle;
|
state.shuffle = !state.shuffle;
|
||||||
},
|
},
|
||||||
toggleNowPlaying(state) {
|
|
||||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
|
||||||
},
|
|
||||||
toggleQueue(state) {
|
toggleQueue(state) {
|
||||||
state.isQueueOpen = !state.isQueueOpen;
|
state.isQueueOpen = !state.isQueueOpen;
|
||||||
},
|
},
|
||||||
@@ -88,7 +81,6 @@ export const {
|
|||||||
toggleMute,
|
toggleMute,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
toggleNowPlaying,
|
|
||||||
toggleQueue,
|
toggleQueue,
|
||||||
} = playerSlice.actions;
|
} = playerSlice.actions;
|
||||||
export default playerSlice.reducer;
|
export default playerSlice.reducer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
type QueueSource =
|
export type QueueSource =
|
||||||
| 'manual'
|
| 'manual'
|
||||||
| 'album'
|
| 'album'
|
||||||
| 'playlist'
|
| 'playlist'
|
||||||
@@ -8,7 +8,7 @@ type QueueSource =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'radio';
|
| 'radio';
|
||||||
|
|
||||||
interface QueueEntry {
|
export interface QueueEntry {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
title: string;
|
title: string;
|
||||||
artistName: string;
|
artistName: string;
|
||||||
@@ -17,7 +17,7 @@ interface QueueEntry {
|
|||||||
albumArtUrl?: string;
|
albumArtUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface QueueState {
|
export interface QueueState {
|
||||||
entries: QueueEntry[];
|
entries: QueueEntry[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
source: QueueSource;
|
source: QueueSource;
|
||||||
@@ -25,52 +25,17 @@ interface QueueState {
|
|||||||
sourceName: string | null;
|
sourceName: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STUB demo queue — purely client-side display data so the player bar and
|
export const queueInitialState: QueueState = {
|
||||||
// queue drawer render with content before the backend exists. Delete this
|
entries: [],
|
||||||
// block (reset entries/currentIndex/source to the empty values) once real
|
currentIndex: -1,
|
||||||
// playback wires tracks into the queue.
|
source: 'manual',
|
||||||
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',
|
|
||||||
sourceId: null,
|
sourceId: null,
|
||||||
sourceName: 'My radio',
|
sourceName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const queueSlice = createSlice({
|
export const queueSlice = createSlice({
|
||||||
name: 'queue',
|
name: 'queue',
|
||||||
initialState,
|
initialState: queueInitialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setQueue(
|
setQueue(
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ interface UiState {
|
|||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
activeModal: string | null;
|
activeModal: string | null;
|
||||||
activeTrackContextMenuId: string | null;
|
activeTrackContextMenuId: string | null;
|
||||||
|
/** Track whose info drawer is open (rightmost drawer); null = closed. */
|
||||||
|
trackInfoId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: UiState = {
|
const initialState: UiState = {
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
activeTrackContextMenuId: null,
|
activeTrackContextMenuId: null,
|
||||||
|
trackInfoId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uiSlice = createSlice({
|
export const uiSlice = createSlice({
|
||||||
@@ -31,6 +34,12 @@ export const uiSlice = createSlice({
|
|||||||
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
||||||
state.activeTrackContextMenuId = action.payload;
|
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,
|
openModal,
|
||||||
closeModal,
|
closeModal,
|
||||||
setActiveContextMenu,
|
setActiveContextMenu,
|
||||||
|
openTrackInfo,
|
||||||
|
closeTrackInfo,
|
||||||
} = uiSlice.actions;
|
} = uiSlice.actions;
|
||||||
export default uiSlice.reducer;
|
export default uiSlice.reducer;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
color: var(--fg-1);
|
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;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+216
-1
@@ -53,12 +53,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.22));
|
||||||
border-right: 1px solid var(--hair);
|
border-right: 1px solid var(--hair);
|
||||||
}
|
}
|
||||||
.sb-scroll {
|
.sb-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
|
min-height: 0; /* allow scroll inside the column flex so .sb-foot stays pinned */
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 14px 12px 6px;
|
padding: 14px 12px 6px;
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -107,6 +111,14 @@
|
|||||||
.nav-item .ph {
|
.nav-item .ph {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: var(--fg-3);
|
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 {
|
.nav-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
@@ -150,6 +162,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -263,9 +277,11 @@
|
|||||||
|
|
||||||
/* connection status pill (used in sidebar foot) */
|
/* connection status pill (used in sidebar foot) */
|
||||||
.conn {
|
.conn {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 5px 11px 5px 9px;
|
padding: 5px 11px 5px 9px;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -707,3 +723,202 @@
|
|||||||
color: var(--fg-3);
|
color: var(--fg-3);
|
||||||
font-size: 12px;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user