Merge pull request #388 from kettui/feature/vite-webapp

Lay the groundwork for webui React transition
This commit is contained in:
BoulderBadgeDad 2026-05-13 14:38:55 -07:00 committed by GitHub
commit dddf761d0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 13226 additions and 2652 deletions

View file

@ -25,6 +25,13 @@ __pycache__/
dist/
build/
# Frontend build artifacts and local dependency caches
webui/.tanstack/
webui/.vite/
webui/node_modules/
webui/test-results/
webui/static/dist/
# Virtual environments
venv/
env/

View file

@ -1,6 +1,16 @@
# SoulSync WebUI Dockerfile
# Multi-architecture support for AMD64 and ARM64
FROM node:24-slim AS webui-builder
WORKDIR /app/webui
COPY webui/package.json webui/package-lock.json ./
RUN npm ci
COPY webui/ ./
RUN npm run build
# Stage 1: Builder — install Python dependencies with compilation tools
FROM python:3.11-slim AS builder
@ -54,6 +64,7 @@ RUN useradd --create-home --shell /bin/bash --uid 1000 soulsync
# in tools/), it gets counted twice in the image. Cin caught this on
# 2026-05-08 — see the .dockerignore comment for the same incident.
COPY --chown=soulsync:soulsync . .
COPY --chown=soulsync:soulsync --from=webui-builder /app/webui/static/dist /app/webui/static/dist
# Create runtime mount-point directories the app expects to exist.
# NOTE: /app/data is for database FILES, /app/database is the Python package

View file

@ -278,19 +278,38 @@ The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To
```bash
git clone https://github.com/Nezreka/SoulSync
cd SoulSync
pip install -r requirements.txt
python -m pip install -r requirements.txt
gunicorn -c gunicorn.conf.py wsgi:application
# Open http://localhost:8008
```
For local development and tests:
### Local Development
Use two terminals so the backend and Vite stay independent:
1. Backend
```bash
python -m pip install -r requirements-dev.txt
gunicorn -c gunicorn.dev.conf.py wsgi:application
```
The dev Gunicorn config watches backend files and restarts the Python server when they change.
2. Frontend
```bash
cd webui
npm ci
npm run dev
```
Vite hot reloads the React side when you change webui files.
Run tests separately when needed:
```bash
pip install -r requirements-dev.txt
pytest
gunicorn -c gunicorn.dev.conf.py wsgi:application
python -m pytest
```
If you want a convenience launcher, `python dev.py` starts both halves together
on any OS. `./dev.sh` remains available as a Unix shell wrapper.
---
## Setup Guide
@ -413,17 +432,14 @@ SoulSync uses a `dev` → `main` flow:
2. Branch off `dev`: `git checkout -b fix/your-change dev`
3. Make your changes and commit
4. Push and open a PR against **`dev`** (not `main`)
5. CI (`build-and-test.yml`) runs ruff lint + compile + pytest on your branch — wait for green
5. CI (`build-and-test.yml`) runs ruff lint + compile + `python -m pytest` on your branch — wait for green
6. A maintainer reviews and merges
### Running locally
```bash
pip install -r requirements-dev.txt
python -m ruff check . # must be 0 errors
python -m pytest # all tests must pass
gunicorn -c gunicorn.dev.conf.py wsgi:application
```
Use the [Local Development](#local-development) section above for the full repo-wide setup and the portable dev launcher.
For web UI work, see [webui/README.md](webui/README.md). It keeps the React-side notes close to the app while this file stays the single place for repo-wide dev instructions.
Ruff config lives in `pyproject.toml`. The ruleset is intentionally lenient — it catches real bugs (undefined names, import shadowing, closure-in-loop) without style nits.

19
core/webui/__init__.py Normal file
View file

@ -0,0 +1,19 @@
"""WebUI delivery helpers."""
from core.webui.assets import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
default_static_url_builder,
get_webui_vite_manifest_path,
load_webui_vite_manifest,
)
from core.webui.spa import should_serve_webui_spa
__all__ = [
"build_webui_vite_assets",
"clear_webui_vite_manifest_cache",
"default_static_url_builder",
"get_webui_vite_manifest_path",
"load_webui_vite_manifest",
"should_serve_webui_spa",
]

126
core/webui/assets.py Normal file
View file

@ -0,0 +1,126 @@
"""WebUI Vite asset helpers."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Callable
from utils.logging_config import get_logger as _create_logger
logger = _create_logger("webui.assets")
DEFAULT_WEBUI_VITE_ENTRY = "src/app/main.tsx"
DEFAULT_WEBUI_VITE_BASE = "/static/dist/"
DEFAULT_WEBUI_VITE_DEV_URL = "http://127.0.0.1:5173"
DEFAULT_WEBUI_VITE_DEV_ENV = "SOULSYNC_WEBUI_VITE_DEV"
DEFAULT_WEBUI_VITE_URL_ENV = "SOULSYNC_WEBUI_VITE_URL"
_MANIFEST_CACHE: dict[str, tuple[float | None, dict[str, Any]]] = {}
def get_webui_vite_manifest_path() -> Path:
"""Return the generated Vite manifest path inside the repo."""
return Path(__file__).resolve().parents[2] / "webui" / "static" / "dist" / ".vite" / "manifest.json"
def clear_webui_vite_manifest_cache() -> None:
"""Clear the in-process manifest cache. Primarily useful for tests."""
_MANIFEST_CACHE.clear()
def default_static_url_builder(filename: str) -> str:
"""Build a Flask-style static URL without requiring Flask imports."""
return f"/static/{filename.lstrip('/')}"
def _env_truthy(value: str | None) -> bool:
return (value or "").lower() in {"1", "true", "yes", "on"}
def _resolve_dev_mode(dev: bool | None) -> bool:
if dev is not None:
return bool(dev)
return _env_truthy(os.environ.get(DEFAULT_WEBUI_VITE_DEV_ENV))
def _resolve_dev_url(dev_url: str | None) -> str:
if dev_url:
return dev_url.rstrip("/")
return os.environ.get(DEFAULT_WEBUI_VITE_URL_ENV, DEFAULT_WEBUI_VITE_DEV_URL).rstrip("/")
def load_webui_vite_manifest(manifest_path: str | Path | None = None) -> dict[str, Any]:
"""Load and cache the generated Vite manifest."""
path = Path(manifest_path) if manifest_path else get_webui_vite_manifest_path()
cache_key = str(path)
manifest_mtime = None
if path.exists():
try:
manifest_mtime = path.stat().st_mtime
except OSError:
manifest_mtime = None
cached = _MANIFEST_CACHE.get(cache_key)
if cached and cached[0] == manifest_mtime:
return cached[1]
if path.exists():
try:
with path.open("r", encoding="utf-8") as handle:
manifest = json.load(handle)
except Exception as exc:
logger.warning("Failed to load webui manifest: %s", exc)
manifest = {}
else:
manifest = {}
_MANIFEST_CACHE[cache_key] = (manifest_mtime, manifest)
return manifest
def build_webui_vite_assets(
placement: str = "body",
*,
dev: bool | None = None,
dev_url: str | None = None,
entry: str = DEFAULT_WEBUI_VITE_ENTRY,
manifest_path: str | Path | None = None,
manifest_loader: Callable[[], dict[str, Any]] | None = None,
static_url_builder: Callable[[str], str] | None = None,
) -> str:
"""Return HTML tags for the WebUI bundle or dev client."""
if placement not in ("head", "body"):
return ""
dev_mode = _resolve_dev_mode(dev)
vite_url = _resolve_dev_url(dev_url)
static_url = static_url_builder or default_static_url_builder
if dev_mode:
if placement == "head":
return ""
base = DEFAULT_WEBUI_VITE_BASE.rstrip("/")
return "\n".join([
f'<script type="module" src="{vite_url}{base}/@vite/client"></script>',
f'<script type="module" src="{vite_url}{base}/{entry.lstrip("/")}"></script>',
])
loader = manifest_loader or (lambda: load_webui_vite_manifest(manifest_path))
manifest = loader()
entry_meta = manifest.get(entry)
if not entry_meta:
return ""
if placement == "head":
return "\n".join(
f'<link rel="stylesheet" href="{static_url(f"dist/{css_file}")}">'
for css_file in entry_meta.get("css", [])
)
entry_file = entry_meta.get("file")
if not entry_file:
return ""
return f'<script type="module" src="{static_url(f"dist/{entry_file}")}"></script>'

23
core/webui/spa.py Normal file
View file

@ -0,0 +1,23 @@
"""WebUI SPA routing helpers."""
from __future__ import annotations
EXACT_EXCLUDED_PATHS = {"/callback", "/status"}
PREFIX_EXCLUDED_PATHS = (
"/api",
"/auth",
"/callback/",
"/deezer/",
"/socket.io",
"/static",
"/stream",
"/tidal/",
)
def should_serve_webui_spa(pathname: str) -> bool:
"""Return True when a request path should fall through to the SPA."""
normalized = pathname.rstrip("/") or "/"
if normalized in EXACT_EXCLUDED_PATHS:
return False
return not normalized.startswith(PREFIX_EXCLUDED_PATHS)

348
dev.py Executable file
View file

@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""SoulSync development launcher.
Starts the backend and Vite dev server together, restarts the backend when
backend source files change, and handles shutdown cleanly across platforms.
"""
from __future__ import annotations
import atexit
import os
import shutil
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT_DIR = Path(__file__).resolve().parent
LOG_DIR = ROOT_DIR / 'logs'
GUNICORN_CONFIG = ROOT_DIR / 'gunicorn.dev.conf.py'
VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
VITE_LOG_FILE = Path(os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(LOG_DIR / 'webui-vite.log')))
INCLUDED_SUFFIXES = {'.py', '.html', '.jinja', '.jinja2'}
SHUTDOWN_GRACE_SECONDS = int(os.environ.get('SOULSYNC_SHUTDOWN_GRACE_SECONDS', '10'))
FORCE_KILL_ON_SHUTDOWN = os.environ.get('SOULSYNC_FORCE_KILL_ON_SHUTDOWN', '1').lower() in {
'1',
'true',
'yes',
'on',
}
shutdown_requested = False
managed_processes: list[tuple[str, subprocess.Popen, object | None]] = []
def resolve_command(*candidates: str) -> str | None:
for candidate in candidates:
resolved = shutil.which(candidate)
if resolved:
return resolved
return None
def is_excluded(path: Path) -> bool:
try:
relative = path.relative_to(ROOT_DIR)
except ValueError:
return False
parts = relative.parts
if not parts:
return False
if any(part == '__pycache__' for part in parts):
return True
if parts[0] in {'.git', 'logs'}:
return True
if len(parts) >= 2 and parts[0] == 'webui' and parts[1] == 'node_modules':
return True
if len(parts) >= 3 and parts[0] == 'webui' and parts[1] == 'static' and parts[2] == 'dist':
return True
return False
def build_backend_env(direct_mode: bool) -> dict[str, str]:
env = os.environ.copy()
env.setdefault('SOULSYNC_WEB_DEV_NO_CACHE', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_DEV', '1')
env.setdefault('SOULSYNC_WEBUI_VITE_URL', VITE_URL)
env.setdefault('SOULSYNC_WEBUI_VITE_LOG', str(VITE_LOG_FILE))
env.setdefault('SOULSYNC_CONFIG_PATH', str(ROOT_DIR / 'config' / 'config.json'))
if direct_mode:
env.setdefault('SOULSYNC_WEB_BIND_HOST', '127.0.0.1')
env.setdefault('SOULSYNC_WEB_BIND_PORT', '8008')
return env
def start_process(label: str, cmd: list[str], *, log_file: Path | None = None, env: dict[str, str] | None = None) -> tuple[subprocess.Popen, object | None]:
log_handle = None
stdout = None
stderr = None
if log_file is not None:
log_file.parent.mkdir(parents=True, exist_ok=True)
log_handle = log_file.open('ab')
stdout = log_handle
stderr = log_handle
creationflags = 0
start_new_session = False
if os.name == 'nt':
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
else:
start_new_session = True
try:
proc = subprocess.Popen(
cmd,
cwd=str(ROOT_DIR),
env=env,
stdin=subprocess.DEVNULL,
stdout=stdout,
stderr=stderr,
creationflags=creationflags,
start_new_session=start_new_session,
)
except Exception:
if log_handle is not None:
log_handle.close()
raise
managed_processes.append((label, proc, log_handle))
return proc, log_handle
def wait_for_exit(proc: subprocess.Popen, seconds: int) -> bool:
checks = max(1, int(seconds * 10))
for _ in range(checks):
if proc.poll() is not None:
return True
time.sleep(0.1)
return proc.poll() is not None
def stop_process(label: str, proc: subprocess.Popen, log_handle: object | None) -> None:
if proc.poll() is not None:
if log_handle is not None:
log_handle.close()
return
print(f'Stopping {label}...')
try:
if os.name == 'nt':
proc.terminate()
else:
os.killpg(proc.pid, signal.SIGTERM)
except ProcessLookupError:
pass
if not wait_for_exit(proc, SHUTDOWN_GRACE_SECONDS):
if not FORCE_KILL_ON_SHUTDOWN:
print(f'{label} did not exit in time; skipping forced kill for this test run.')
else:
print(f'{label} did not exit in time; forcing shutdown...')
if os.name == 'nt':
subprocess.run(
['taskkill', '/T', '/F', '/PID', str(proc.pid)],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
try:
os.killpg(proc.pid, signal.SIGKILL)
except ProcessLookupError:
pass
wait_for_exit(proc, 5)
if log_handle is not None:
log_handle.close()
def cleanup() -> None:
global shutdown_requested
if shutdown_requested:
return
shutdown_requested = True
for label, proc, log_handle in reversed(managed_processes):
stop_process(label, proc, log_handle)
def compute_backend_watch_state() -> str:
rows: list[str] = []
for dirpath, dirnames, filenames in os.walk(ROOT_DIR):
current_dir = Path(dirpath)
if is_excluded(current_dir):
dirnames[:] = []
continue
dirnames[:] = [
name
for name in dirnames
if not is_excluded(current_dir / name) and name != '__pycache__'
]
for filename in filenames:
path = current_dir / filename
if path.suffix not in INCLUDED_SUFFIXES:
continue
try:
stat = path.stat()
except FileNotFoundError:
continue
rows.append(f'{stat.st_mtime_ns} {path}')
return '\n'.join(sorted(rows))
def start_vite() -> subprocess.Popen:
npm = resolve_command('npm', 'npm.cmd')
if npm is None:
raise SystemExit('npm is required to run the Vite dev server.')
print(f'Starting Vite dev server at {VITE_URL}...')
vite_cmd = [
npm,
'--prefix',
str(ROOT_DIR / 'webui'),
'run',
'dev',
'--',
'--host',
'127.0.0.1',
'--port',
'5173',
]
proc, _ = start_process('Vite dev server', vite_cmd, log_file=VITE_LOG_FILE, env=os.environ.copy())
return proc
def wait_for_vite_ready(vite_proc: subprocess.Popen) -> None:
ready_url = f'{VITE_URL}/static/dist/@vite/client'
vite_ready = False
for _ in range(50):
if vite_proc.poll() is not None:
print('Warning: Vite dev server exited before it became ready.')
break
try:
with urllib.request.urlopen(ready_url, timeout=1) as response:
if response.status < 400:
vite_ready = True
break
except (urllib.error.URLError, TimeoutError, OSError):
pass
time.sleep(0.2)
if vite_ready:
print('Vite dev server is ready.')
else:
print('Warning: timed out waiting for the Vite dev server.')
print('The backend will still start, but the frontend may not hot-reload yet.')
def start_backend() -> tuple[subprocess.Popen, object | None]:
backend_mode = os.environ.get('SOULSYNC_DEV_BACKEND', '').strip().lower()
direct_mode = backend_mode == 'direct'
gunicorn_mode = backend_mode == 'gunicorn'
if not backend_mode:
if os.name == 'nt':
direct_mode = True
elif resolve_command('gunicorn') is None:
print('gunicorn not found; falling back to direct Python server.')
direct_mode = True
else:
gunicorn_mode = True
print('Starting SoulSync web server...')
if gunicorn_mode:
gunicorn = resolve_command('gunicorn')
if gunicorn is None:
raise SystemExit('gunicorn is not available but SOULSYNC_DEV_BACKEND=gunicorn was requested.')
print(f'Using Gunicorn config: {GUNICORN_CONFIG}')
cmd = [gunicorn, '-c', str(GUNICORN_CONFIG), 'wsgi:application']
else:
print('Using direct Python server for backend.')
cmd = [sys.executable, str(ROOT_DIR / 'web_server.py')]
proc, log_handle = start_process(
'SoulSync web server',
cmd,
env=build_backend_env(direct_mode),
)
return proc, log_handle
def watch_and_run_backend() -> None:
last_state = compute_backend_watch_state()
backend_proc, backend_log = start_backend()
try:
while not shutdown_requested:
time.sleep(1)
if backend_proc.poll() is not None:
print('SoulSync web server exited. Restarting...')
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
last_state = compute_backend_watch_state()
continue
current_state = compute_backend_watch_state()
if current_state != last_state:
print('Detected backend file changes. Restarting SoulSync web server...')
last_state = current_state
stop_process('SoulSync web server', backend_proc, backend_log)
managed_processes.pop()
backend_proc, backend_log = start_backend()
finally:
if backend_proc.poll() is None:
stop_process('SoulSync web server', backend_proc, backend_log)
if managed_processes:
managed_processes.pop()
def main() -> int:
if not (ROOT_DIR / 'webui' / 'node_modules').is_dir():
print('webui/node_modules is missing.')
print('Run: cd webui && npm ci')
return 1
vite_proc = start_vite()
try:
wait_for_vite_ready(vite_proc)
print(f'Vite log: {VITE_LOG_FILE}')
print('Backend file watching is enabled.')
watch_and_run_backend()
finally:
cleanup()
return 0
def _handle_signal(signum: int, _frame) -> None:
raise SystemExit(130 if signum == signal.SIGINT else 143)
signal.signal(signal.SIGINT, _handle_signal)
if hasattr(signal, 'SIGTERM'):
signal.signal(signal.SIGTERM, _handle_signal)
if hasattr(signal, 'SIGBREAK'):
signal.signal(signal.SIGBREAK, _handle_signal)
atexit.register(cleanup)
if __name__ == '__main__':
raise SystemExit(main())

16
dev.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/sh
# Compatibility wrapper for the Python launcher.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if command -v python3 >/dev/null 2>&1; then
exec python3 "$SCRIPT_DIR/dev.py" "$@"
fi
if command -v python >/dev/null 2>&1; then
exec python "$SCRIPT_DIR/dev.py" "$@"
fi
echo "Python is required to run the SoulSync dev launcher."
exit 1

View file

@ -0,0 +1,267 @@
# WebUI Page Migration Analysis
Snapshot date: 2026-05-02
## Summary
- The shell route manifest now has 18 page ids.
- `issues` is still the only React-owned route.
- Since the last snapshot, the biggest changes are:
- `downloads` was renamed into `search`.
- The live queue became `active-downloads`.
- `watchlist` and `wishlist` became full sidebar pages.
- `tools` was split off from `dashboard`.
- `artists` is no longer a route id.
- The shell is also more modular now. The old monolithic `script.js` has been split across `core.js`, `init.js`, `shared-helpers.js`, and feature modules such as `search.js`, `api-monitor.js`, `pages-extra.js`, `stats-automations.js`, and `wishlist-tools.js`.
- Current profile compatibility still normalizes old `downloads` and `artists` references to `search`, so the docs and the route ids are not always using the same historical language.
## What Changed Since The Last Snapshot
- `search` is now the canonical route for the old download/search experience.
- `active-downloads` owns the dedicated live queue that used to sit inside the search flow.
- `watchlist` and `wishlist` moved out of dashboard-era chrome and into their own routes.
- `tools` moved off the dashboard into a dedicated sidebar page.
- `dashboard` is a bit narrower now, because several operational surfaces were split out.
- `artist-detail` is still a first-class route, but its permission relationship is now tied to `library` and `search`, not to an `artists` page.
- The contextual help system still contains some historical `downloads` and `artists` wording, so those labels should be treated as legacy text rather than route ids.
## Current Architecture
- `webui/index.html` still hosts the Flask-rendered shell, the sidebar, the media player, the legacy `.page` containers, and the React mount point.
- `webui/static/core.js` now holds a lot of the shared global state that used to live in the old monolith.
- `webui/static/init.js` still owns page activation, permission gating, nav highlighting, legacy routing, and the `window.SoulSyncWebRouter` bridge.
- `webui/static/shell-bridge.js` and the TanStack Router adapter still decide whether a route is handled by the React host or handed back to the legacy shell.
- `issues` remains the reference pattern for React-owned pages: route manifest ownership, shell bridge integration, route-local data loading, and detail-modal behavior all live in the React subtree.
- The legacy shell is now spread across feature modules rather than one giant coordinator file, which makes the migration seams a little clearer than they were a month ago.
### Route and Compatibility Notes
- Manifest page ids: `dashboard`, `sync`, `search`, `discover`, `playlist-explorer`, `watchlist`, `wishlist`, `automations`, `active-downloads`, `library`, `tools`, `artist-detail`, `stats`, `import`, `settings`, `issues`, `help`, `hydrabase`.
- `downloads` and `artists` are no longer manifest ids.
- HTML `.page` containers exist for every legacy page plus `webui-react-root` for React.
- `watchlist`, `wishlist`, and `active-downloads` are now standalone route targets instead of dashboard overlays.
- `tools` is now a dedicated page, so dashboard can be treated as a monitoring hub instead of the one-stop maintenance surface.
- `help` and `issues` remain always-allowed for non-admins.
- `settings` remains admin-only.
- `artist-detail` is allowed when the profile can access `library` or `search`.
## Cross-Cutting Features
- Profile and permission routing still live in the shell bootstrap.
- Shell chrome and nav highlighting are still shared shell responsibilities.
- Media player behavior, queue handling, and global overlays still cut across multiple pages.
- Socket/WebSocket and polling behavior remain the biggest migration risks for live pages.
- The help system, tours, and helper annotations still reference some historical route names, so route-migration work should use the manifest as the source of truth.
- Visual effects such as `particles.js` and `worker-orbs.js` remain shell-global.
## Scoring Rubric
Each page is scored from 1 to 5 on five axes:
- Rendering surface size: HTML/UI area and number of distinct render states
- State/coupling complexity: amount of local state plus coupling to other pages or shell-global state
- Async/realtime complexity: fetch fan-out, polling, WebSocket/live progress, streaming, or long-running workflows
- Cross-cutting shell dependency: reliance on shared shell behaviors, globals, overlays, or non-route contracts
- Testability/parity difficulty: how hard it is to prove route parity without regressions
Rollups:
- Migration effort
- `Low`: total score 9-11
- `Medium`: total score 12-17
- `High`: total score 18-21
- `Very High`: total score 22-25
- Regression risk
- `Low`: mostly isolated UI with limited async and minimal shell coupling
- `Medium`: moderate data flow or workflow complexity with bounded blast radius
- `High`: broad coupling, many async states, or sensitive user workflows
## Summary Matrix
| Page | Owner | Scores (R/S/A/C/T) | Effort | Risk | Recommended Wave |
| --- | --- | --- | --- | --- | --- |
| `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 0 |
| `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 |
| `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
| `stats` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 |
| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 |
| `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 |
| `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
| `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 |
| `active-downloads` | Legacy | 3 / 4 / 4 / 3 / 4 | High | High | Wave 4 |
| `tools` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 4 |
| `dashboard` | Legacy | 4 / 4 / 4 / 4 / 4 | High | High | Wave 5 |
| `discover` | Legacy | 5 / 5 / 4 / 4 / 5 | Very High | High | Wave 6 |
| `playlist-explorer` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 7 |
| `library` | Legacy | 4 / 5 / 4 / 4 / 5 | Very High | High | Wave 8 |
| `artist-detail` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 8 |
| `sync` | Legacy | 5 / 5 / 5 / 4 / 5 | Very High | High | Wave 9 |
| `settings` | Legacy | 5 / 5 / 4 / 5 / 5 | Very High | High | Wave 10 |
| `automations` | Legacy | 4 / 5 / 4 / 3 / 4 | High | High | Wave 10 |
## Page Catalog
### Wave 0: Baseline
#### `issues`
- Current owner: React.
- Primary files: `webui/src/routes/issues/*`, `webui/src/platform/shell/*`, `webui/src/app/router.tsx`.
- Main surface: counts cards, filtered issue list, issue-detail modal, mutation flows.
- Key coupling: shell page gating, shell nav badge refresh, bridge-controlled chrome, React Query cache.
- Why it stays first: it is already the canonical React route pattern and the migration baseline.
### Wave 1: Safest wins
#### `help`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/docs.js`, `webui/static/helper.js`.
- Main surface: docs navigation, long-form sections, screenshots, lightbox behavior.
- Key coupling: mostly shell chrome and docs deep linking.
- Recommendation: still one of the safest early migrations, but keep the helper system shell-owned for now.
#### `hydrabase`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/init.js`.
- Main surface: connection state, saved credentials, peer count, comparison list.
- Key coupling: profile gating and a small amount of shell state.
- Recommendation: low-risk route with a narrow surface.
#### `stats`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`.
- Main surface: listening stats, charts, ranked lists, database storage visualization.
- Key coupling: chart rendering, some deep links back into library routes.
- Recommendation: early migration candidate with good parity-test potential.
#### `import`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: staging files, album and singles matching, suggestion cards, processing queue.
- Key coupling: settings-derived staging path assumptions and downstream library state.
- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `stats`.
### Wave 2: Search split
#### `search`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/search.js`, `webui/static/downloads.js`, `webui/static/shared-helpers.js`.
- Main surface: basic search, enhanced search, source picker, fallback banner, download launch flow.
- Key coupling: global search widget parity, shared search controller, download modal handoff, legacy DOM ids that still say `downloads`.
- Recommendation: this is the renamed download/search surface, so it should be treated as a distinct migration from the queue view, not as the old monolith.
### Wave 3: Watchlist pair
#### `watchlist`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/helper.js`.
- Main surface: watched-artist grid, per-artist config, scan status, global override banner, bulk actions.
- Key coupling: discovery and wishlist generation, scan polling, per-profile access rules.
- Recommendation: good mid-complexity route once the shell bridge and route-local data patterns are stable.
#### `wishlist`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/api-monitor.js`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
- Main surface: track queue, cycle state, live processing, nebula visualization, countdown timers.
- Key coupling: watchlist scans, playlist sync handoff, download processing, live polling.
- Recommendation: visually distinctive but still bounded enough to follow `watchlist` in the same program wave.
### Wave 4: Operational split
#### `active-downloads`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/pages-extra.js`.
- Main surface: centralized live download list, status filters, batch grouping, batch history, cancellation controls.
- Key coupling: polling every few seconds, download batch hydration, nav badge counts, server-side download state.
- Recommendation: the old embedded queue moved here, so this page should be treated as the queue sibling of `search`.
#### `tools`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: database updater, metadata updater, quality scan, duplicate clean, retag, backups, maintenance sections.
- Key coupling: lots of operational actions and several background jobs, but less dashboard chrome than before.
- Recommendation: split-off from the dashboard, but still operational enough to stay in a later wave.
### Wave 5: Monitoring hub
#### `dashboard`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/init.js`, `webui/static/wishlist-tools.js`, `webui/static/api-monitor.js`, `webui/static/worker-orbs.js`.
- Main surface: service cards, enrichment workers, library status, recent syncs, system stats, activity feed, quick nav.
- Key coupling: almost every global subsystem eventually shows up here.
- Recommendation: narrower than the old snapshot because tools moved out, but still one of the central shell surfaces.
### Wave 6: Broad discovery surface
#### `discover`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/discover.js`, `webui/static/helper.js`.
- Main surface: hero carousel, recent releases, genre browser, decade browser, similar artists, seasonal picks.
- Key coupling: watchlist-derived recommendations, discovery pool, download handoffs, many semi-independent sections.
- Recommendation: broad rendering surface and heavy fetch fan-out make this a high-risk migration.
### Wave 7: Visual interaction route
#### `playlist-explorer`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/wishlist-tools.js`, `webui/static/helper.js`.
- Main surface: playlist cards, discovery tree, selection model, zoom/pan interactions, wishlist submission.
- Key coupling: document-level pointer handling, discovery workflow, artist navigation.
- Recommendation: interactive enough to wait until the team is comfortable migrating richer stateful views.
### Wave 8: Library stack
#### `library`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/helper.js`.
- Main surface: searchable artist grid, watchlist filters, pagination, download bubbles, deep links to detail.
- Key coupling: tightly bound to `artist-detail`, watchlist systems, and library-wide expectations.
- Recommendation: should be migrated alongside the detail route, not as an isolated quick win.
#### `artist-detail`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/library.js`, `webui/static/downloads.js`, `webui/static/helper.js`.
- Main surface: hero section, discography views, bulk operations, inline editing, tag writing, reorganize, quality actions.
- Key coupling: explicitly coupled to `library`, plus downloads, playback, metadata services, and file-organization settings.
- Recommendation: treat this as part of the library stack and keep it out of early waves.
### Wave 9: Multi-source orchestration
#### `sync`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/sync-spotify.js`, `webui/static/sync-services.js`, `webui/static/wishlist-tools.js`.
- Main surface: mirrored playlists, source tabs, discovery, sync history, playlist import flows, M3U export.
- Key coupling: the heaviest async orchestration in the app, with long-running workflows and state rehydration.
- Recommendation: one of the last major migrations.
### Wave 10: Final complex authoring/admin routes
#### `settings`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/settings.js`, `webui/static/helper.js`.
- Main surface: service credentials, download config, quality settings, file organization, appearance, advanced settings.
- Key coupling: almost every other page depends on settings-derived behavior or stored configuration.
- Recommendation: late migration because the blast radius is very large.
#### `automations`
- Current owner: Legacy.
- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`.
- Main surface: automation list, visual builder, run history, one-click hub groups, config editing.
- Key coupling: nested editable state, polling, run/deploy/toggle flows, and other system-level actions.
- Recommendation: save this for the final wave with the other complex authoring surfaces.
## Platform Unlocks
- `search` likely unlocks reusable search-controller and download-launch primitives for the global search widget and other search entry points.
- `watchlist` likely unlocks artist-card, per-artist config, and scan-status primitives for `discover` and `wishlist`.
- `wishlist` likely unlocks queue/cycle visualization, live polling, and retry-state handling for `active-downloads` and sync-driven download flows.
- `active-downloads` likely unlocks batch grouping, queue filtering, and cancellation patterns for other download-related surfaces.
- `tools` likely unlocks maintenance-card and operational-action patterns that can be reused from `dashboard`.
- `library` + `artist-detail` still unlock entity-detail patterns, bulk actions, and file-management workflows.
## Why Earlier Waves Are Safer
- Wave 1 routes are either mostly static or bounded data UIs with limited cross-route side effects.
- Wave 2 adds the renamed search surface without dragging in the full queue history.
- Wave 3 introduces the new watchlist/wishlist split, which is important but still narrower than discovery or library management.
- Wave 4 adds the live queue and tools split once the route-local patterns are already in place.
- Wave 5 keeps the dashboard after its maintenance responsibilities have been peeled away.
- Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage.
## Final Recommendation
- Keep `issues` as the reference implementation and preserve the existing bridge contract.
- Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history.
- Migrate the safe routes first: `help`, `hydrabase`, `stats`, and `import`.
- Use `search` as the next meaningful proving ground now that the download queue has been split out.
- Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk.

View file

@ -1,11 +1,27 @@
"""Gunicorn configuration for local development."""
from pathlib import Path
import os
bind = "127.0.0.1:8008"
worker_class = "gthread"
workers = 1
threads = 4
reload = True
raw_env = ["SOULSYNC_WEB_DEV_NO_CACHE=1"]
_ROOT_DIR = Path(__file__).resolve().parent
_VITE_URL = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
_VITE_LOG = os.environ.get('SOULSYNC_WEBUI_VITE_LOG', str(_ROOT_DIR / 'logs' / 'webui-vite.log'))
# Dev Gunicorn config and Vite dev server are paired on purpose.
raw_env = [
"SOULSYNC_WEB_DEV_NO_CACHE=1",
"SOULSYNC_WEBUI_VITE_DEV=1",
f"SOULSYNC_CONFIG_PATH={os.environ.get('SOULSYNC_CONFIG_PATH', str(_ROOT_DIR / 'config' / 'config.json'))}",
f"SOULSYNC_LOG_LEVEL={os.environ.get('SOULSYNC_LOG_LEVEL', '')}",
f"SOULSYNC_WEBUI_VITE_URL={_VITE_URL}",
f"SOULSYNC_WEBUI_VITE_LOG={_VITE_LOG}",
]
# Keep requests from hanging forever on slow external services.
timeout = 120

View file

@ -0,0 +1,96 @@
"""Tests for the WebUI asset helpers."""
from __future__ import annotations
import json
import os
import time
import pytest
from core.webui import (
build_webui_vite_assets,
clear_webui_vite_manifest_cache,
load_webui_vite_manifest,
should_serve_webui_spa,
)
def test_build_webui_vite_assets_renders_dev_scripts():
html = build_webui_vite_assets("body", dev=True, dev_url="http://127.0.0.1:5173")
assert html == (
'<script type="module" src="http://127.0.0.1:5173/static/dist/@vite/client"></script>\n'
'<script type="module" src="http://127.0.0.1:5173/static/dist/src/app/main.tsx"></script>'
)
def test_build_webui_vite_assets_renders_manifest_assets():
manifest = {
"src/app/main.tsx": {
"css": ["assets/main.css"],
"file": "assets/main.js",
}
}
html_head = build_webui_vite_assets(
"head",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
html_body = build_webui_vite_assets(
"body",
manifest_loader=lambda: manifest,
static_url_builder=lambda filename: f"/assets/{filename}",
)
assert html_head == '<link rel="stylesheet" href="/assets/dist/assets/main.css">'
assert html_body == '<script type="module" src="/assets/dist/assets/main.js"></script>'
@pytest.mark.parametrize(
"pathname",
[
"/api/issues",
"/auth/spotify",
"/callback",
"/callback/extra",
"/deezer/callback",
"/socket.io",
"/static/app.js",
"/stream/file",
"/tidal/callback",
"/status",
],
)
def test_should_serve_webui_spa_blocks_reserved_paths(pathname):
assert should_serve_webui_spa(pathname) is False
@pytest.mark.parametrize(
"pathname",
[
"/",
"/issues",
"/issues?issueId=7",
"/artists/Opeth",
"/discover",
],
)
def test_should_serve_webui_spa_allows_client_routes(pathname):
assert should_serve_webui_spa(pathname) is True
def test_load_webui_vite_manifest_reloads_when_file_changes(tmp_path):
clear_webui_vite_manifest_cache()
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/one.js"}}))
first = load_webui_vite_manifest(manifest_path)
assert first["src/app/main.tsx"]["file"] == "assets/one.js"
manifest_path.write_text(json.dumps({"src/app/main.tsx": {"file": "assets/two.js"}}))
future = time.time() + 10
os.utime(manifest_path, (future, future))
second = load_webui_vite_manifest(manifest_path)
assert second["src/app/main.tsx"]["file"] == "assets/two.js"

View file

@ -20,7 +20,7 @@ from pathlib import Path
from urllib.parse import urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g, abort
from flask import Flask, abort, render_template, request, jsonify, redirect, send_file, send_from_directory, Response, session, g
from flask_socketio import SocketIO, emit, join_room, leave_room
from utils.logging_config import get_logger, setup_logging
from utils.async_helpers import run_async
@ -97,6 +97,7 @@ from core.metadata.cache import get_metadata_cache
from core.metadata import registry as metadata_registry
from core.metadata import is_internal_image_host
from core.metadata import normalize_image_url as fix_artist_image_url
from core.webui import build_webui_vite_assets, should_serve_webui_spa
from core.metadata.registry import (
clear_cached_metadata_client,
get_metadata_source_label,
@ -292,13 +293,12 @@ app.jinja_env.auto_reload = DEV_STATIC_NO_CACHE
app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 if DEV_STATIC_NO_CACHE else 31536000
# Cache-bust query string for static assets — appended to every
# url_for('static', ...) URL via the context processor below. Computed
# once per process start so each server restart invalidates the
# browser's cached copy of every JS/CSS file. This is the surefire
# fix for "user has stale JS even after Ctrl+Shift+R" — the URL
# itself changes, so the browser cannot reuse a previously-cached
# response no matter what its Cache-Control header said.
# Cache-bust query string for static assets. Computed once per process
# start so each server restart invalidates the browser's cached copy of
# every JS/CSS file. This is the surefire fix for "user has stale JS
# even after Ctrl+Shift+R" — the URL itself changes, so the browser
# cannot reuse a previously-cached response no matter what its
# Cache-Control header said.
import time as _cache_bust_time
_STATIC_CACHE_BUST = str(int(_cache_bust_time.time()))
@ -371,6 +371,11 @@ def _log_rejected_socketio_origin():
request.headers.get('X-Forwarded-Proto', ''),
)
@app.context_processor
def inject_webui_assets():
return {
'vite_assets': build_webui_vite_assets,
}
# --- Profile Context (before_request hook) ---
@app.before_request
@ -514,7 +519,26 @@ def get_spotify_client_for_profile(profile_id=None):
return metadata_registry.get_spotify_client_for_profile(profile_id)
# Valid page IDs for profile permission validation
VALID_PAGE_IDS = {'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', 'library', 'import', 'settings', 'help'}
VALID_PAGE_IDS = {
'dashboard',
'sync',
'search',
'discover',
'playlist-explorer',
'watchlist',
'wishlist',
'automations',
'active-downloads',
'library',
'tools',
'artist-detail',
'stats',
'import',
'settings',
'help',
'hydrabase',
'issues',
}
def check_download_permission():
"""Check if current profile has download permission. Returns error response or None if allowed."""
@ -3325,9 +3349,9 @@ def service_worker():
@app.route('/<path:page>')
def spa_catch_all(page):
# Serve index.html for client-side routes; let Flask handle real routes first.
if page.startswith(('api/', 'static/', 'auth/', 'callback', 'deezer/', 'tidal/', 'status')):
if not should_serve_webui_spa(f'/{page}'):
abort(404)
return render_template('index.html')
return index()
# --- API Endpoints ---
@ -35798,8 +35822,11 @@ def start_runtime_services():
# Direct execution: python web_server.py (dev/Windows fallback)
# Production should use: gunicorn -c gunicorn.conf.py wsgi:application
if _DIRECT_RUN:
web_run_host = os.environ.get('SOULSYNC_WEB_BIND_HOST', '0.0.0.0')
web_run_port = int(os.environ.get('SOULSYNC_WEB_BIND_PORT', '8008'))
display_host = '127.0.0.1' if web_run_host in {'0.0.0.0', '::'} else web_run_host
logger.info("Starting SoulSync Web UI Server...")
logger.info("Open your browser and navigate to http://127.0.0.1:8008")
logger.info(f"Open your browser and navigate to http://{display_host}:{web_run_port}")
logger.info("Tip: For production, use gunicorn -c gunicorn.conf.py wsgi:application")
start_runtime_services()
socketio.run(app, host='0.0.0.0', port=8008, debug=False, allow_unsafe_werkzeug=True)
socketio.run(app, host=web_run_host, port=web_run_port, debug=False, allow_unsafe_werkzeug=True)

4
webui/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.vite
node_modules
static/dist
test-results

18
webui/.oxfmtrc.json Normal file
View file

@ -0,0 +1,18 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"],
"singleQuote": true,
"sortImports": {
"groups": [
"type-import",
["value-builtin", "value-external"],
"type-internal",
"value-internal",
["type-parent", "type-sibling", "type-index"],
["value-parent", "value-sibling", "value-index"],
"unknown"
]
},
"sortPackageJson": true,
"trailingComma": "all"
}

8
webui/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": ["src/routeTree.gen.ts"],
"options": {
"typeAware": true,
"typeCheck": true
}
}

138
webui/README.md Normal file
View file

@ -0,0 +1,138 @@
# WebUI Hybrid Rendering
SoulSync's web UI is in a transition phase:
- most pages still render through the legacy vanilla JS shell
- `/issues` is rendered by the new React app
- a small shell bridge keeps both runtimes aware of the active page, profile context, and navigation state
## How It Fits Together
```mermaid
flowchart LR
Browser["Browser parses /webui/index.html"]
Legacy["Legacy shell scripts\n(core.js -> ... -> init.js)"]
Bridge["shell-bridge.js\nwindow.SoulSyncWebShellBridge"]
React["Vite React app\nsrc/app/main.tsx"]
Router["TanStack Router\nwindow.SoulSyncWebRouter"]
Browser --> Legacy
Browser --> React
Legacy --> Bridge
React --> Router
Router --> Bridge
Bridge --> Legacy
```
## Runtime Roles
- `webui/static/init.js`
- boots the legacy shell
- selects the active profile
- handles the legacy page loading flow
- `webui/static/shell-bridge.js`
- owns the browser-side bridge object
- exposes `window.SoulSyncWebShellBridge`
- owns the shared page chrome and route handoff helpers
- `webui/src/app/main.tsx`
- mounts the React app
- binds `window.SoulSyncWebRouter`
- `webui/src/platform/shell/route-controllers.tsx`
- listens for bridge readiness
- keeps React pages aligned with the shell
## Load Order
The current order in `index.html` matters:
1. legacy shell scripts load first
2. `init.js` sets up the shell runtime
3. `shell-bridge.js` publishes the bridge and shared chrome helpers after the shell state exists
4. the Vite React app is injected through `{{ vite_assets('body') }}` and boots as a module after parsing
That order avoids load-time references to missing globals and keeps the React side able to react to bridge readiness events. The React entry can start fetching early, but the shell bridge and legacy globals are already available by the time the React runtime starts acting on them.
## Notes
- The bridge is intentionally small and browser-only.
- This is the start of the migration, not a full replacement of the legacy shell.
- When adding another React page, check whether it needs:
- a route entry in `webui/src/platform/shell/route-manifest.ts`
- bridge typings in `webui/src/platform/shell/globals.d.ts`
- a legacy fallback path in `webui/static/init.js`
- bridge glue or handoff logic in `webui/static/shell-bridge.js`
## Folder Layout
The React webui uses a small set of predictable folders so route slices stay easy to extend,
test, and understand.
```text
webui/src/
app/ React bootstrap, router, query client, shared API client
components/ Shared UI primitives
platform/ Shell bridge and browser/platform integration
routes/ Route-local code and TanStack Router pages
test/ Shared test utilities and setup helpers
```
### Route Slices
- Keep route-specific code inside `webui/src/routes/<route>/`.
- Put the routing entry in `route.tsx`.
- Put route-local UI in a `-ui/` folder.
- Prefix non-routing files with `-` so TanStack Router ignores them.
- Keep the route slice small and cohesive.
- Prefer a few files with clear responsibilities over many tiny files with overlapping names.
Example:
```text
webui/src/routes/issues/
route.tsx
-issues.types.ts
-issues.api.ts
-issues.helpers.ts
-issues.api.test.ts
-issues.helpers.test.ts
-ui/
issues-page.tsx
issue-detail-modal.tsx
issue-domain-host.tsx
```
The initial `issues` slice is the model to follow:
- `-issues.api.ts` holds request code and query options
- `-issues.helpers.ts` holds pure normalization and formatting
- `-issues.types.ts` holds shared types
- `-ui/` holds the page, modal, and legacy handoff UI
### Shared Code
- Put reusable UI in `webui/src/components/`.
- Put shell integration in `webui/src/platform/`.
- Put bootstrap and app-wide wiring in `webui/src/app/`.
- Move code up a level only when it is genuinely shared.
- Avoid creating new conventions that overlap with existing ones.
### Testing Choices
We have a lot of testing tools available, but we do not need all of them for every feature.
- Use plain unit tests for pure functions and small transforms.
- Use React component or route tests when the behavior lives in the UI or router.
- Use MSW-backed tests when request shape, response handling, or error handling matters.
- Use Playwright when the behavior is best proven end-to-end with the server and browser together.
- Prefer the smallest test setup that still proves the thing that can regress.
## Development
The repo root now owns the full local-dev instructions. Start there for the
portable launcher and backend/frontend setup:
1. [README.md](../README.md) for the end-to-end dev flow
2. `npm run check` and `npm run fix` for React-side linting and formatting

View file

@ -12,6 +12,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
{{ vite_assets('head')|safe }}
</head>
<body>
@ -103,6 +104,8 @@
<option value="sync">Sync</option>
<option value="search">Search</option>
<option value="discover">Discover</option>
<option value="watchlist">Watchlist</option>
<option value="wishlist">Wishlist</option>
<option value="automations">Automations</option>
<option value="active-downloads">Downloads</option>
<option value="library">Library</option>
@ -117,6 +120,8 @@
<label><input type="checkbox" value="sync" checked> Sync</label>
<label><input type="checkbox" value="search" checked> Search</label>
<label><input type="checkbox" value="discover" checked> Discover</label>
<label><input type="checkbox" value="watchlist" checked> Watchlist</label>
<label><input type="checkbox" value="wishlist" checked> Wishlist</label>
<label><input type="checkbox" value="automations" checked> Automations</label>
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
<label><input type="checkbox" value="library" checked> Library</label>
@ -189,7 +194,7 @@
<!-- Navigation Section -->
<nav class="sidebar-nav">
<button class="nav-button active" data-page="dashboard">
<button class="nav-button" data-page="dashboard">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/><line x1="5" y1="20" x2="5" y2="22"/><line x1="8" y1="19" x2="8" y2="22"/></svg></span>
<span class="nav-text">Dashboard</span>
</button>
@ -301,9 +306,10 @@
<div class="main-content">
<!-- Global particle canvas for page background animations -->
<canvas id="page-particles-canvas"></canvas>
<div id="webui-react-root" class="page" data-react-app="router"></div>
<!-- Dashboard Page -->
<div class="page active" id="dashboard-page">
<div class="page" id="dashboard-page">
<div class="dashboard-container">
<div class="dashboard-header">
<div class="header-text">
@ -1844,7 +1850,7 @@
</div>
</div>
<!-- Downloads Page -->
<!-- Search Page -->
<div class="page" id="search-page">
<!--
This top-level container replicates the QSplitter from downloads.py,
@ -2091,8 +2097,6 @@
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
<!-- List View -->
@ -2168,7 +2172,7 @@
</div>
</div>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Library.</div>
</div>
</div>
</div>
@ -2545,7 +2549,7 @@
</div>
<!-- Similar Artists Section (works for both library and source artists).
Uses its own scoped IDs because the inline Artists page has a section
Uses its own scoped IDs because the artist detail view has a section
with the same base IDs and both elements live in the DOM at once. -->
<div class="similar-artists-section" id="ad-similar-artists-section">
<div class="similar-artists-header">
@ -6145,85 +6149,6 @@
</div>
</div>
</div>
<!-- Issues Page -->
<div class="page" id="issues-page">
<div class="issues-container">
<div class="issues-header">
<div class="issues-header-left">
<h2 class="issues-title">Issues</h2>
<p class="issues-subtitle" id="issues-subtitle">Track and resolve library problems</p>
</div>
<div class="issues-header-right">
<div class="issues-filters" id="issues-filters">
<select id="issues-filter-status" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Status</option>
<option value="open" selected>Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select id="issues-filter-category" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Categories</option>
<optgroup label="Track Issues">
<option value="wrong_track">Wrong Track</option>
<option value="wrong_artist">Wrong Artist</option>
<option value="wrong_album">Wrong Album</option>
<option value="audio_quality">Audio Quality</option>
</optgroup>
<optgroup label="Album Issues">
<option value="wrong_cover">Wrong Cover Art</option>
<option value="duplicate_tracks">Duplicate Tracks</option>
<option value="missing_tracks">Missing Tracks</option>
<option value="incomplete_album">Incomplete Album</option>
</optgroup>
<optgroup label="Both">
<option value="wrong_metadata">Wrong Metadata</option>
<option value="other">Other</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="issues-stats" id="issues-stats"></div>
<div class="issues-list" id="issues-list">
<div class="issues-empty">Loading issues...</div>
</div>
</div>
</div>
<!-- Report Issue Modal -->
<div class="modal-overlay hidden" id="report-issue-overlay">
<div class="enhanced-bulk-modal report-issue-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="report-issue-title">Report an Issue</h3>
<button class="enhanced-bulk-modal-close" onclick="closeReportIssueModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="report-issue-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReportIssueModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="report-issue-submit-btn" onclick="submitIssue()">Submit Issue</button>
</div>
</div>
</div>
<!-- Issue Detail Modal (Admin) -->
<div class="modal-overlay hidden" id="issue-detail-overlay">
<div class="enhanced-bulk-modal issue-detail-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="issue-detail-title">Issue Details</h3>
<button class="enhanced-bulk-modal-close" onclick="closeIssueDetailModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="issue-detail-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer" id="issue-detail-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>
</div>
</div>
</div>
<!-- Help & Docs Page -->
@ -7851,8 +7776,9 @@
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
{{ vite_assets('body')|safe }}
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<!-- Split modules (was: script.js) — core.js must load first, init.js before shell-bridge.js -->
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='media-player.js', v=static_v) }}"></script>
@ -7871,6 +7797,7 @@
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shell-bridge.js', v=static_v) }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.

5028
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

44
webui/package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "soulsync-webui",
"private": true,
"type": "module",
"scripts": {
"check": "oxfmt --check src && oxlint --type-check src",
"dev": "vite",
"fix": "oxfmt src && oxlint --type-check src --fix",
"build": "vite build",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@tanstack/react-form": "^1.29.1",
"@tanstack/react-query": "^5.100.5",
"@tanstack/react-router": "^1.168.24",
"clsx": "^2.1.1",
"ky": "^2.0.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"zod": "^4.4.2"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.27",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.2",
"msw": "^2.14.2",
"oxfmt": "^0.47.0",
"oxlint": "^1.62.0",
"oxlint-tsgolint": "^0.22.1",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vitest": "^4.1.5"
},
"packageManager": "npm@11.13.0"
}

View file

@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
use: {
launchOptions: {
executablePath: '/usr/bin/chromium',
},
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:8008',
trace: 'on-first-retry',
},
});

View file

@ -0,0 +1,50 @@
import type { ResponsePromise } from 'ky';
import { HTTPError } from 'ky';
import { describe, expect, it, vi } from 'vitest';
import { readJson } from './api-client';
function createHttpError(body: unknown, status = 400) {
const response = new Response(JSON.stringify(body), {
status,
statusText: 'Bad Request',
headers: {
'Content-Type': 'application/json',
},
});
const request = new Request('https://example.com/api/test');
const error = new HTTPError(response, request, {} as any);
error.data = body;
return error;
}
describe('readJson', () => {
it('returns parsed JSON', async () => {
const json = vi.fn().mockResolvedValue({ ok: true });
const promise = { json } as unknown as ResponsePromise<{ ok: boolean }>;
await expect(readJson(promise)).resolves.toEqual({ ok: true });
expect(json).toHaveBeenCalledTimes(1);
});
it('keeps HTTPError instances intact and uses the payload message', async () => {
const error = createHttpError({ error: 'Nope' }, 403);
const json = vi.fn().mockRejectedValue(error);
const promise = { json } as unknown as ResponsePromise<unknown>;
const result = readJson(promise);
await expect(result).rejects.toBe(error);
await expect(result).rejects.toHaveProperty('message', 'Nope');
});
it('falls back to the HTTPError message when the payload is unhelpful', async () => {
const error = createHttpError({ detail: 'missing error field' }, 404);
const json = vi.fn().mockRejectedValue(error);
const promise = { json } as unknown as ResponsePromise<unknown>;
const result = readJson(promise);
await expect(result).rejects.toBe(error);
await expect(result).rejects.toHaveProperty('message', error.message);
});
});

View file

@ -0,0 +1,41 @@
import type { ResponsePromise } from 'ky';
import ky, { HTTPError } from 'ky';
const apiBaseUrl =
typeof globalThis.location === 'object'
? new URL('/api/', globalThis.location.origin).toString()
: 'http://localhost/api/';
export const apiClient = ky.create({
baseUrl: apiBaseUrl,
retry: 0,
});
export async function readJson<T>(promise: ResponsePromise<T>): Promise<T> {
try {
return await promise.json<T>();
} catch (error) {
if (error instanceof HTTPError) {
error.message = getHttpErrorMessage(error.data, error.message);
}
throw error;
}
}
type JsonErrorPayload = {
error?: unknown;
message?: unknown;
};
function getHttpErrorMessage(data: unknown, fallback: string): string {
if (typeof data === 'string' && data.trim()) return data;
if (!data || typeof data !== 'object') return fallback;
const payload = data as JsonErrorPayload;
if (typeof payload.error === 'string' && payload.error.trim()) return payload.error;
if (typeof payload.message === 'string' && payload.message.trim()) return payload.message;
return fallback;
}

23
webui/src/app/main.tsx Normal file
View file

@ -0,0 +1,23 @@
import '@vitejs/plugin-react/preamble';
import { createRoot } from 'react-dom/client';
import { bindWindowWebRouter } from '@/platform/shell/bridge';
import { ROUTER_ROOT_ID } from '@/platform/shell/route-controllers';
import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router';
export async function bootstrapApp() {
const container = document.getElementById(ROUTER_ROOT_ID);
if (!container) return null;
const queryClient = createAppQueryClient();
const router = createAppRouter({ queryClient });
bindWindowWebRouter(router);
createRoot(container).render(<AppRouterProvider router={router} queryClient={queryClient} />);
return { queryClient, router };
}
void bootstrapApp();

View file

@ -0,0 +1,13 @@
import { QueryClient } from '@tanstack/react-query';
export function createAppQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 10_000,
},
},
});
}

View file

@ -0,0 +1,179 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SHELL_PROFILE_CONTEXT_CHANGED_EVENT,
type ShellBridge,
type ShellProfileContext,
type ShellPageId,
} from '@/platform/shell/bridge';
import { createAppQueryClient } from './query-client';
import { AppRouterProvider, createAppRouter } from './router';
function mockIssuesFetch() {
return vi.fn(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes('/api/issues/counts')) {
return new Response(
JSON.stringify({
success: true,
counts: { open: 2, in_progress: 1, resolved: 0, dismissed: 0, total: 3 },
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (url.includes('/api/issues?')) {
return new Response(
JSON.stringify({
success: true,
total: 1,
issues: [
{
id: 7,
entity_type: 'album',
entity_id: 'album-7',
category: 'wrong_cover',
title: 'Wrong cover art',
status: 'open',
priority: 'normal',
snapshot_data: '{}',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch request: ${url}`);
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 1, isAdmin: false })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
describe('createAppRouter', () => {
beforeEach(() => {
vi.stubGlobal('fetch', mockIssuesFetch());
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
window.SoulSyncIssueDomain = undefined;
});
it('creates one shared query client and applies router defaults', () => {
const queryClient = createAppQueryClient();
const router = createAppRouter({ queryClient });
expect(router.options.context?.queryClient).toBe(queryClient);
expect(router.options.defaultPreload).toBe('intent');
expect(router.options.defaultPreloadStaleTime).toBe(0);
expect(router.options.scrollRestoration).toBe(true);
expect(router.options.defaultErrorComponent).toBeDefined();
expect(router.options.defaultNotFoundComponent).toBeDefined();
});
it('renders migrated React routes directly and updates shell chrome', async () => {
window.SoulSyncWebShellBridge = createShellBridge();
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(screen.getByTestId('issues-board')).toBeInTheDocument();
});
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('issues');
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('issues');
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).not.toHaveBeenCalled();
});
it('routes non-migrated paths through the legacy fallback handler', async () => {
window.SoulSyncWebShellBridge = createShellBridge();
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/search'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/search');
});
});
it('redirects disallowed React routes back to the profile home page', async () => {
window.SoulSyncWebShellBridge = createShellBridge({
isPageAllowed: vi.fn((pageId) => pageId !== 'issues'),
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(history.location.pathname).toBe('/discover');
});
});
it('waits for profile context before rendering React routes', async () => {
const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null);
window.SoulSyncWebShellBridge = createShellBridge({
getCurrentProfileContext,
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
expect(screen.queryByTestId('issues-board')).not.toBeInTheDocument();
getCurrentProfileContext.mockReturnValue({ profileId: 1, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await waitFor(() => {
expect(screen.getByTestId('issues-board')).toBeInTheDocument();
});
});
it('redirects the root route to the profile home page', async () => {
window.SoulSyncWebShellBridge = createShellBridge({
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'search'),
});
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/'] });
const router = createAppRouter({ history, queryClient });
render(<AppRouterProvider router={router} queryClient={queryClient} />);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/search');
});
expect(history.location.pathname).toBe('/search');
});
});

82
webui/src/app/router.tsx Normal file
View file

@ -0,0 +1,82 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query';
import { createRouter, type RouterHistory } from '@tanstack/react-router';
import { RouterProvider } from '@tanstack/react-router';
import { getShellBridge } from '@/platform/shell/bridge';
import { routeTree } from '@/routeTree.gen';
import { createAppQueryClient } from './query-client';
export interface AppRouterContext {
queryClient: QueryClient;
platform: {
getShellBridge: typeof getShellBridge;
};
}
export function createAppRouter(
options: {
history?: RouterHistory;
queryClient?: QueryClient;
context?: Partial<AppRouterContext>;
} = {},
) {
const queryClient = options.queryClient ?? createAppQueryClient();
const context: AppRouterContext = {
...options.context,
queryClient,
platform: {
getShellBridge,
...options.context?.platform,
},
};
return createRouter({
routeTree,
history: options.history,
context,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
defaultErrorComponent: DefaultErrorComponent,
defaultNotFoundComponent: DefaultNotFoundComponent,
});
}
export function AppRouterProvider({
router,
queryClient,
}: {
router: ReturnType<typeof createAppRouter>;
queryClient: QueryClient;
}) {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
export function DefaultErrorComponent() {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>Please refresh the page and try again.</p>
</div>
);
}
export function DefaultNotFoundComponent() {
return (
<div role="status">
<h2>Page not found</h2>
<p>The requested page could not be found.</p>
</div>
);
}

View file

@ -0,0 +1,110 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 10999;
background: rgba(5, 9, 16, 0.86);
backdrop-filter: blur(18px);
}
.viewport {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
}
.popup {
width: min(1060px, 100%);
max-height: min(90vh, 980px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.headerContent {
min-width: 0;
}
.headerMeta {
margin-top: 6px;
}
.title {
margin: 0;
font-size: 1.4rem;
}
.close {
flex-shrink: 0;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.body {
flex: 1 1 auto;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px;
}
.footer {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
padding: 16px 22px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
@media (max-width: 640px) {
.viewport {
padding: 12px;
}
.popup {
max-height: calc(100vh - 24px);
border-radius: 22px;
}
.header {
padding: 18px;
}
.body {
padding: 18px;
}
.footer {
flex-direction: column;
align-items: stretch;
padding: 0 18px 18px;
}
.footer > button {
width: 100%;
}
}

View file

@ -0,0 +1,59 @@
import type { ReactNode } from 'react';
import { Dialog } from '@base-ui/react/dialog';
import clsx from 'clsx';
import styles from './dialog.module.css';
export function DialogFrame({
children,
className,
onOpenChange,
open,
}: {
children: ReactNode;
className?: string;
onOpenChange: (open: boolean) => void;
open: boolean;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Backdrop className={styles.backdrop} />
<Dialog.Viewport className={styles.viewport}>
<Dialog.Popup className={clsx(styles.popup, className)}>{children}</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
);
}
export function DialogHeader({
children,
closeLabel = 'Close dialog',
title,
}: {
children?: ReactNode;
closeLabel?: string;
title: ReactNode;
}) {
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Dialog.Title className={styles.title}>{title}</Dialog.Title>
{children ? <div className={styles.headerMeta}>{children}</div> : null}
</div>
<Dialog.Close className={styles.close} aria-label={closeLabel}>
×
</Dialog.Close>
</div>
);
}
export function DialogBody({ children }: { children: ReactNode }) {
return <div className={styles.body}>{children}</div>;
}
export function DialogFooter({ children }: { children: ReactNode }) {
return <div className={styles.footer}>{children}</div>;
}

View file

@ -0,0 +1 @@
export { DialogBody, DialogFooter, DialogFrame, DialogHeader } from './dialog';

View file

@ -0,0 +1,294 @@
.field {
display: flex;
flex-direction: column;
gap: 10px;
}
.fieldHeader {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
}
.fieldHelper {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
line-height: 1.45;
}
.fieldControl {
display: flex;
flex-direction: column;
}
.textInput,
.textArea {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font: inherit;
font-size: 14px;
line-height: 1.5;
padding: 12px 14px;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.textInput::placeholder,
.textArea::placeholder {
color: rgba(255, 255, 255, 0.38);
}
.textInput:hover,
.textArea:hover {
border-color: rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.06);
}
.textInput:focus,
.textArea:focus {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
background: rgba(255, 255, 255, 0.07);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.textArea {
min-height: 128px;
resize: vertical;
}
.select {
width: auto;
min-width: 130px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.05);
background-image:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.035)),
rgba(255, 255, 255, 0.05),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='%23888'%3E%3Cpath d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat, no-repeat, no-repeat;
background-position:
0 0,
0 0,
right 12px center;
background-size:
auto,
auto,
10px 6px;
color: #fff;
font: inherit;
font-size: 13px;
line-height: 1.5;
padding: 8px 32px 8px 12px;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
cursor: pointer;
}
.select:hover {
border-color: rgba(255, 255, 255, 0.16);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
rgba(255, 255, 255, 0.06);
}
.select:focus {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
rgba(255, 255, 255, 0.07);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.select option,
.select optgroup {
background: #1a1a2e;
color: #fff;
}
.optionCardGroup {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.optionCard {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 14px 16px;
text-align: left;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.03)),
rgba(255, 255, 255, 0.03);
color: #fff;
cursor: pointer;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
}
.optionCard:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.04);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
}
.optionCardSelected {
border-color: rgba(var(--accent-light-rgb), 0.45);
background:
linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.05);
box-shadow:
0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
0 14px 32px rgba(0, 0, 0, 0.26);
}
.optionCardIcon {
flex-shrink: 0;
font-size: 18px;
line-height: 1;
margin-top: 1px;
}
.optionCardBody {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 5px;
}
.optionCardTitle {
font-size: 14px;
font-weight: 600;
line-height: 1.3;
}
.optionCardDescription {
color: rgba(255, 255, 255, 0.48);
font-size: 12px;
line-height: 1.45;
}
.optionButtonGroup {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.optionButton {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
min-width: 80px;
padding: 10px 14px;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
}
.optionButton:hover {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
.optionButtonSelected {
border-color: rgba(var(--accent-light-rgb), 0.5);
background: rgba(var(--accent-rgb), 0.18);
box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
min-height: 36px;
padding: 8px 12px;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
.button:hover:not(:disabled) {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
}
.button:focus-visible {
outline: none;
border-color: rgba(var(--accent-light-rgb), 0.55);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.formError {
color: #ff8d8d;
font-size: 12px;
line-height: 1.45;
}
.formActions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}

View file

@ -0,0 +1,131 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import {
Button,
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
Select,
TextArea,
TextInput,
} from './form';
function FormDemo() {
const [title, setTitle] = useState('');
const [details, setDetails] = useState('');
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
const [status, setStatus] = useState('open');
return (
<form>
<FormField label="Title" helperText="Short summary" htmlFor="title-input">
<TextInput
id="title-input"
placeholder="Enter title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</FormField>
<FormField label="Details" helperText="Longer explanation" htmlFor="details-input">
<TextArea
id="details-input"
placeholder="Enter details"
rows={3}
value={details}
onChange={(event) => setDetails(event.target.value)}
/>
</FormField>
<FormField label="Category" helperText="Pick one">
<OptionCardGroup>
<OptionCard
description="Album art is wrong"
icon="🖼️"
onClick={() => setCategory('wrong_cover')}
selected={category === 'wrong_cover'}
title="Wrong Cover"
/>
<OptionCard
description="Metadata needs fixing"
icon="🏷️"
onClick={() => setCategory('wrong_metadata')}
selected={category === 'wrong_metadata'}
title="Wrong Metadata"
/>
</OptionCardGroup>
</FormField>
<FormField label="Priority" helperText="Set urgency">
<OptionButtonGroup>
{(['low', 'normal', 'high'] as const).map((value) => (
<OptionButton
key={value}
onClick={() => setPriority(value)}
selected={priority === value}
>
{value[0].toUpperCase()}
{value.slice(1)}
</OptionButton>
))}
</OptionButtonGroup>
</FormField>
<FormField label="Status" helperText="Shared select primitive" htmlFor="status-select">
<Select
id="status-select"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<option value="open">Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
</Select>
</FormField>
<FormError message="Validation failed" />
<FormActions>
<Button type="button">Cancel</Button>
<Button type="submit">Save</Button>
</FormActions>
</form>
);
}
describe('form primitives', () => {
it('render accessible controls and support selection state', () => {
render(<FormDemo />);
expect(screen.getByLabelText('Title')).toHaveAttribute('placeholder', 'Enter title');
expect(screen.getByLabelText('Details')).toHaveAttribute('placeholder', 'Enter details');
expect(screen.getByText('Short summary')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
expect(screen.getByLabelText('Status')).toHaveValue('open');
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(wrongMetadata);
expect(wrongCover).toHaveAttribute('aria-pressed', 'false');
expect(wrongMetadata).toHaveAttribute('aria-pressed', 'true');
const highPriority = screen.getByRole('button', { name: 'High' });
expect(highPriority).toHaveAttribute('aria-pressed', 'false');
fireEvent.click(highPriority);
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
});

View file

@ -0,0 +1,190 @@
import { Button as BaseButton } from '@base-ui/react/button';
import { Field } from '@base-ui/react/field';
import { Input as BaseInput } from '@base-ui/react/input';
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
import clsx from 'clsx';
import {
forwardRef,
type ComponentPropsWithoutRef,
type ButtonHTMLAttributes,
type SelectHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react';
import styles from './form.module.css';
export interface FormFieldProps {
children: ReactNode;
className?: string;
error?: ReactNode;
helperText?: ReactNode;
htmlFor?: string;
label: ReactNode;
}
export function FormField({
children,
className,
error,
helperText,
htmlFor,
label,
}: FormFieldProps) {
return (
<Field.Root className={clsx(styles.field, className)}>
<div className={styles.fieldHeader}>
{htmlFor ? (
<label className={styles.fieldLabel} htmlFor={htmlFor}>
{label}
</label>
) : (
<Field.Label className={styles.fieldLabel}>{label}</Field.Label>
)}
{helperText ? (
<Field.Description className={styles.fieldHelper}>{helperText}</Field.Description>
) : null}
</div>
<div className={styles.fieldControl}>{children}</div>
{error ? <FormError message={error} /> : null}
</Field.Root>
);
}
type BaseInputProps = ComponentPropsWithoutRef<typeof BaseInput>;
export type TextInputProps = Omit<BaseInputProps, 'className'> & {
className?: string;
};
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function TextInput(
{ className, ...props },
ref,
) {
return <BaseInput ref={ref} className={clsx(styles.textInput, className)} {...props} />;
});
export type TextAreaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function TextArea(
{ className, ...props },
ref,
) {
return <textarea ref={ref} className={clsx(styles.textArea, className)} {...props} />;
});
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
{ className, ...props },
ref,
) {
return <select ref={ref} className={clsx(styles.select, className)} {...props} />;
});
export function OptionCardGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={clsx(styles.optionCardGroup, className)}>{children}</div>;
}
export interface OptionCardProps extends Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'title' | 'value'
> {
className?: string;
description?: ReactNode;
icon?: ReactNode;
selected?: boolean;
title?: ReactNode;
value?: string;
}
export const OptionCard = forwardRef<HTMLButtonElement, OptionCardProps>(function OptionCard(
{ className, children, description, icon, selected = false, title, type = 'button', ...props },
ref,
) {
return (
<BaseToggle
ref={ref}
pressed={selected}
className={clsx(styles.optionCard, selected && styles.optionCardSelected, className)}
type={type}
{...props}
>
{children ?? (
<>
{icon ? <div className={styles.optionCardIcon}>{icon}</div> : null}
<div className={styles.optionCardBody}>
{title ? <div className={styles.optionCardTitle}>{title}</div> : null}
{description ? <div className={styles.optionCardDescription}>{description}</div> : null}
</div>
</>
)}
</BaseToggle>
);
});
export function OptionButtonGroup({
className,
children,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={clsx(styles.optionButtonGroup, className)}>{children}</div>;
}
export interface OptionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'value'> {
className?: string;
selected?: boolean;
value?: string;
}
export const OptionButton = forwardRef<HTMLButtonElement, OptionButtonProps>(function OptionButton(
{ className, children, selected = false, type = 'button', ...props },
ref,
) {
return (
<BaseToggle
ref={ref}
pressed={selected}
className={clsx(styles.optionButton, selected && styles.optionButtonSelected, className)}
type={type}
{...props}
>
{children}
</BaseToggle>
);
});
type BaseButtonProps = ComponentPropsWithoutRef<typeof BaseButton>;
export type ButtonProps = Omit<BaseButtonProps, 'className'> & {
className?: string;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, type = 'button', ...props },
ref,
) {
return <BaseButton ref={ref} className={clsx(styles.button, className)} type={type} {...props} />;
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
if (!message) return null;
return (
<div className={clsx(styles.formError, className)} role="alert">
{message}
</div>
);
}
export function FormActions({ className, children }: { children: ReactNode; className?: string }) {
return <div className={clsx(styles.formActions, className)}>{children}</div>;
}

View file

@ -0,0 +1,13 @@
export {
Button,
FormActions,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
Select,
TextArea,
TextInput,
} from './form';

View file

@ -0,0 +1 @@
export { Show } from './show';

View file

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { Show } from './show';
describe('Show', () => {
it('renders children when the condition is true', () => {
render(
<Show when={true}>
<span>Visible</span>
</Show>,
);
expect(screen.getByText('Visible')).toBeInTheDocument();
});
it('renders fallback when the condition is false', () => {
render(
<Show fallback={<span>Hidden</span>} when={false}>
<span>Visible</span>
</Show>,
);
expect(screen.getByText('Hidden')).toBeInTheDocument();
expect(screen.queryByText('Visible')).not.toBeInTheDocument();
});
it('supports render-prop children', () => {
render(<Show when="Ada">{(name) => <span>{name}</span>}</Show>);
expect(screen.getByText('Ada')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
type ShowChildren<T> = ReactNode | ((value: NonNullable<T>) => ReactNode);
export function Show<T>({
fallback = null,
children,
when,
}: {
children: ShowChildren<T>;
fallback?: ReactNode;
when: T;
}) {
if (!when) {
return <>{fallback}</>;
}
if (typeof children === 'function') {
return <>{(children as (value: NonNullable<T>) => ReactNode)(when as NonNullable<T>)}</>;
}
return <>{children}</>;
}

View file

@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellProfileContext } from './bridge';
import { SHELL_PROFILE_CONTEXT_CHANGED_EVENT, waitForShellContext } from './bridge';
describe('waitForShellContext', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = undefined;
});
it('resolves immediately when the shell already has a profile', async () => {
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
await expect(waitForShellContext()).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 2,
isAdmin: true,
},
});
});
it('waits for the legacy shell to publish profile context', async () => {
const getCurrentProfileContext = vi.fn<() => ShellProfileContext | null>(() => null);
window.SoulSyncWebShellBridge = {
getProfileHomePage: vi.fn(() => 'discover'),
isPageAllowed: vi.fn(() => true),
activateLegacyPath: vi.fn(),
getCurrentProfileContext,
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
const contextPromise = waitForShellContext();
getCurrentProfileContext.mockReturnValue({ profileId: 5, isAdmin: false });
window.dispatchEvent(new CustomEvent(SHELL_PROFILE_CONTEXT_CHANGED_EVENT));
await expect(contextPromise).resolves.toEqual({
bridge: window.SoulSyncWebShellBridge,
profile: {
profileId: 5,
isAdmin: false,
},
});
});
});

View file

@ -0,0 +1,101 @@
import type { AnyRouter } from '@tanstack/react-router';
import {
getShellRouteByPageId,
normalizeShellPath,
resolveShellPageFromPath,
shellRouteManifest,
type ShellPageId,
type ShellRouteDefinition,
} from './route-manifest';
export interface ShellProfileContext {
profileId: number;
isAdmin: boolean;
}
export interface ShellContext {
bridge: ShellBridge;
profile: ShellProfileContext;
}
export type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>;
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export const SHELL_PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
export function getShellBridge(): ShellBridge | null {
return window.SoulSyncWebShellBridge ?? null;
}
export function getShellProfileContext(bridge = getShellBridge()): ShellProfileContext | null {
return bridge?.getCurrentProfileContext() ?? null;
}
export function getShellContext(bridge = getShellBridge()): ShellContext | null {
const profile = getShellProfileContext(bridge);
if (!bridge || !profile) return null;
return { bridge, profile };
}
export function getProfileHomePath(bridge = getShellBridge()): `/${string}` {
const pageId = bridge?.getProfileHomePage() ?? 'discover';
return getShellRouteByPageId(pageId)?.path ?? '/discover';
}
export async function waitForShellContext(): Promise<ShellContext> {
const currentContext = getShellContext();
if (currentContext) return currentContext;
return await new Promise<ShellContext>((resolve) => {
const cleanup = () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.removeEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
};
const settleIfReady = () => {
const shell = getShellContext();
if (!shell) return;
cleanup();
resolve(shell);
};
const handleReady = () => {
settleIfReady();
};
const handleProfileChange = () => {
settleIfReady();
};
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
window.addEventListener(SHELL_PROFILE_CONTEXT_CHANGED_EVENT, handleProfileChange);
settleIfReady();
});
}
export function bindWindowWebRouter(router: AnyRouter) {
window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest],
getCurrentPath() {
return normalizeShellPath(window.location.pathname);
},
resolvePageId(pathname: string) {
return resolveShellPageFromPath(pathname);
},
async navigateToPage(pageId, options) {
const route = getShellRouteByPageId(pageId);
if (!route) return false;
await router.navigate({
href: route.path,
replace: options?.replace === true,
});
return true;
},
};
}
export type { ShellPageId, ShellRouteDefinition };

41
webui/src/platform/shell/globals.d.ts vendored Normal file
View file

@ -0,0 +1,41 @@
import type {
DownloadMissingAlbumWorkflowInput,
WishlistAlbumWorkflowInput,
} from '@/platform/workflows/album-workflows';
import type { IssueDomainBridge } from '@/routes/issues/-issues.types';
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
declare global {
interface Window {
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;
openAddToWishlistAlbum: (input: WishlistAlbumWorkflowInput) => void | Promise<void>;
notify?: (message: string, type?: string) => void;
};
SoulSyncWebRouter?: {
routeManifest: ShellRouteDefinition[];
getCurrentPath: () => string;
resolvePageId: (pathname: string) => ShellPageId | null;
navigateToPage: (
pageId: ShellPageId,
options?: {
replace?: boolean;
},
) => Promise<boolean>;
};
SoulSyncWebShellBridge?: {
getCurrentProfileContext: () => ShellProfileContext | null;
isPageAllowed: (pageId: ShellPageId) => boolean;
getProfileHomePage: () => ShellPageId;
resolveLegacyPath: (pathname: string) => ShellPageId | null;
setActivePageChrome: (pageId: ShellPageId) => void;
activateLegacyPath: (pathname: string) => void;
showReactHost: (pageId: ShellPageId) => void;
};
}
}
export {};

View file

@ -0,0 +1,57 @@
import { useRouteContext, useRouter } from '@tanstack/react-router';
import { useEffect, useLayoutEffect } from 'react';
import { getProfileHomePath, type ShellContext, type ShellPageId } from './bridge';
export const ROUTER_ROOT_ID = 'webui-react-root';
export function useShellContext(): ShellContext {
const context = useRouteContext({
from: '__root__',
select: (routeContext) => routeContext.shell,
});
return context;
}
export function useShellBridge() {
return useShellContext().bridge;
}
export function useProfile() {
return useShellContext().profile;
}
export function LegacyRouteController({ pathname }: { pathname: string }) {
const bridge = useShellBridge();
useEffect(() => {
if (!bridge) return;
bridge.activateLegacyPath(pathname);
}, [bridge, pathname]);
return null;
}
export function useReactPageShell(pageId: ShellPageId) {
const bridge = useShellBridge();
const router = useRouter();
useLayoutEffect(() => {
if (!bridge) return;
if (!bridge.isPageAllowed(pageId)) return;
bridge.setActivePageChrome(pageId);
bridge.showReactHost(pageId);
}, [bridge, pageId]);
useEffect(() => {
if (!bridge) return;
if (!bridge.isPageAllowed(pageId)) {
void router.navigate({ href: getProfileHomePath(bridge), replace: true });
return;
}
}, [bridge, pageId, router]);
return bridge;
}

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import {
getShellRouteByPageId,
legacyShellRoutes,
normalizeShellPath,
reactShellRoutes,
resolveLegacyShellPageFromPath,
resolveShellPageFromPath,
shellRouteManifest,
} from './route-manifest';
describe('shellRouteManifest', () => {
it('resolves page ids from explicit paths', () => {
expect(resolveShellPageFromPath('/issues')).toBe('issues');
expect(resolveShellPageFromPath('/discover')).toBe('discover');
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artists')).toBeNull();
});
it('treats the root path as unresolved so the shell can redirect to the profile home', () => {
expect(resolveShellPageFromPath('/')).toBeNull();
});
it('normalizes trailing slashes before resolving', () => {
expect(normalizeShellPath('/issues/')).toBe('/issues');
expect(resolveShellPageFromPath('/issues/')).toBe('issues');
});
it('keeps a route entry for every manifest page id', () => {
expect(shellRouteManifest).not.toHaveLength(0);
expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard');
expect(getShellRouteByPageId('hydrabase')?.path).toBe('/hydrabase');
expect(getShellRouteByPageId('watchlist')?.path).toBe('/watchlist');
expect(getShellRouteByPageId('tools')?.path).toBe('/tools');
expect(getShellRouteByPageId('artist-detail')?.path).toBe('/artist-detail');
});
it('tracks whether a route is rendered by React or the legacy shell', () => {
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['issues']);
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
});
it('only resolves legacy page ids for legacy-owned paths', () => {
expect(resolveLegacyShellPageFromPath('/search')).toBe('search');
expect(resolveLegacyShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveLegacyShellPageFromPath('/tools')).toBe('tools');
expect(resolveLegacyShellPageFromPath('/issues')).toBeNull();
expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull();
});
});

View file

@ -0,0 +1,80 @@
export const shellPageIds = [
'dashboard',
'sync',
'search',
'discover',
'playlist-explorer',
'watchlist',
'wishlist',
'automations',
'active-downloads',
'library',
'tools',
'artist-detail',
'stats',
'import',
'settings',
'issues',
'help',
'hydrabase',
] as const;
export type ShellPageId = (typeof shellPageIds)[number];
export type ShellRouteKind = 'legacy' | 'react';
export interface ShellRouteDefinition {
pageId: ShellPageId;
path: `/${string}`;
kind: ShellRouteKind;
}
export const shellRouteManifest: readonly ShellRouteDefinition[] = [
{ pageId: 'dashboard', path: '/dashboard', kind: 'legacy' },
{ pageId: 'sync', path: '/sync', kind: 'legacy' },
{ pageId: 'search', path: '/search', kind: 'legacy' },
{ pageId: 'discover', path: '/discover', kind: 'legacy' },
{ pageId: 'playlist-explorer', path: '/playlist-explorer', kind: 'legacy' },
{ pageId: 'watchlist', path: '/watchlist', kind: 'legacy' },
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
{ pageId: 'import', path: '/import', kind: 'legacy' },
{ pageId: 'library', path: '/library', kind: 'legacy' },
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
{ pageId: 'stats', path: '/stats', kind: 'legacy' },
{ pageId: 'settings', path: '/settings', kind: 'legacy' },
{ pageId: 'issues', path: '/issues', kind: 'react' },
{ pageId: 'help', path: '/help', kind: 'legacy' },
{ pageId: 'hydrabase', path: '/hydrabase', kind: 'legacy' },
] as const;
const routeByPageId = new Map(shellRouteManifest.map((route) => [route.pageId, route]));
const routeByPath = new Map(shellRouteManifest.map((route) => [route.path, route]));
export const reactShellRoutes = shellRouteManifest.filter((route) => route.kind === 'react');
export const legacyShellRoutes = shellRouteManifest.filter((route) => route.kind === 'legacy');
export function normalizeShellPath(pathname: string): string {
if (!pathname) return '/';
if (pathname === '/') return '/';
const normalized = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
return normalized || '/';
}
export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition | undefined {
return routeByPageId.get(pageId);
}
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
}
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
return getShellRouteByPath(pathname)?.pageId ?? null;
}
export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null {
const route = getShellRouteByPath(pathname);
return route?.kind === 'legacy' ? route.pageId : null;
}

View file

@ -0,0 +1,194 @@
import { apiClient } from '@/app/api-client';
export interface AlbumWorkflowLaunchInput {
spotifyAlbumId?: string;
artistName?: string;
albumName?: string;
source?: string;
}
export interface DownloadMissingAlbumWorkflowInput {
virtualPlaylistId: string;
playlistName: string;
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
forceDownload: boolean;
registerDownload?: boolean;
}
export interface WishlistAlbumWorkflowInput {
tracks: Array<Record<string, unknown>>;
album: Record<string, unknown>;
artist: Record<string, unknown>;
albumType: string;
}
interface AlbumSearchResult {
id?: string;
name?: string;
title?: string;
artist?: string;
}
interface AlbumApiResponse {
id?: string;
name?: string;
album_type?: string;
images?: Array<{ url?: string }>;
image_url?: string | null;
release_date?: string;
total_tracks?: number;
artists?: Array<{ id?: string | null; name?: string }>;
tracks?: Array<Record<string, unknown>>;
}
interface EnhancedSearchResponse {
spotify_albums?: AlbumSearchResult[];
itunes_albums?: AlbumSearchResult[];
}
function getWorkflowBridge() {
const bridge = window.SoulSyncWorkflowActions;
if (!bridge) {
throw new Error('Album workflow host is not ready yet');
}
return bridge;
}
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
if (window.SoulSyncWorkflowActions?.notify) {
window.SoulSyncWorkflowActions.notify(message, type);
return;
}
window.showToast?.(message, type);
}
async function searchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumSearchResult> {
const query = `${input.artistName || ''} ${input.albumName || ''}`.trim();
if (!query) {
throw new Error('No album ID or artist/album info available');
}
const searchData =
(await apiClient
.post('enhanced-search', {
json: { query },
})
.json<EnhancedSearchResponse>()) ?? {};
const foundAlbum = searchData.spotify_albums?.[0] ?? searchData.itunes_albums?.[0];
if (!foundAlbum?.id) {
throw new Error(
`Could not find "${input.albumName || 'album'}" by ${input.artistName || 'unknown artist'}`,
);
}
return foundAlbum;
}
async function fetchAlbum(input: AlbumWorkflowLaunchInput): Promise<AlbumApiResponse> {
let albumId = input.spotifyAlbumId || '';
let albumName = input.albumName || '';
let artistName = input.artistName || '';
if (!albumId) {
const foundAlbum = await searchAlbum(input);
albumId = foundAlbum.id || '';
albumName = foundAlbum.name || foundAlbum.title || albumName;
artistName = foundAlbum.artist || artistName;
}
const searchParams = new URLSearchParams({ name: albumName, artist: artistName });
try {
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(albumId)}`, { searchParams })
.json<AlbumApiResponse>()) ?? {}
);
} catch (error) {
if (!input.spotifyAlbumId || (!input.artistName && !input.albumName)) {
throw error;
}
const foundAlbum = await searchAlbum(input);
const fallbackParams = new URLSearchParams({
name: foundAlbum.name || foundAlbum.title || albumName,
artist: foundAlbum.artist || artistName,
});
return (
(await apiClient
.get(`spotify/album/${encodeURIComponent(foundAlbum.id || '')}`, {
searchParams: fallbackParams,
})
.json<AlbumApiResponse>()) ?? {}
);
}
}
async function resolveAlbumWorkflowData(input: AlbumWorkflowLaunchInput) {
const albumData = await fetchAlbum(input);
if (!albumData.tracks?.length) {
throw new Error(`No tracks available for "${input.albumName || albumData.name || 'album'}"`);
}
const albumArtists = albumData.artists?.length
? albumData.artists
: [{ name: input.artistName || 'Unknown Artist' }];
const artistName = input.artistName || albumArtists[0]?.name || 'Unknown Artist';
const albumType = albumData.album_type || 'album';
const album = {
name: albumData.name || input.albumName || 'Unknown Album',
id: albumData.id || input.spotifyAlbumId || '',
album_type: albumType,
images: albumData.images || [],
image_url: albumData.image_url || albumData.images?.[0]?.url || null,
release_date: albumData.release_date,
total_tracks: albumData.total_tracks,
artists: albumArtists,
};
const tracks = albumData.tracks.map((track) => ({
...track,
artists: albumArtists,
album,
}));
return {
album,
albumType,
artist: { id: `workflow_${artistName}`, name: artistName, image_url: '' },
artistName,
tracks,
};
}
export async function launchAlbumDownloadWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, artistName, tracks } = await resolveAlbumWorkflowData(input);
const resolvedAlbumId = String(album.id || input.spotifyAlbumId || Date.now());
const source = input.source || 'album';
await bridge.openDownloadMissingAlbum({
virtualPlaylistId: `${source}_download_${resolvedAlbumId}`,
playlistName: `[${artistName}] ${String(album.name || 'Unknown Album')}`,
tracks,
album,
artist,
albumType,
forceDownload: true,
registerDownload: true,
});
}
export async function launchAlbumWishlistWorkflow(input: AlbumWorkflowLaunchInput) {
const bridge = getWorkflowBridge();
const { album, albumType, artist, tracks } = await resolveAlbumWorkflowData(input);
await bridge.openAddToWishlistAlbum({
album,
artist,
tracks,
albumType,
});
notify('Wishlist workflow opened', 'success');
}

View file

@ -0,0 +1,95 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
import { Route as IndexRouteImport } from './routes/index'
const SplatRoute = SplatRouteImport.update({
id: '/$',
path: '/$',
getParentRoute: () => rootRouteImport,
} as any)
const IssuesRouteRoute = IssuesRouteRouteImport.update({
id: '/issues',
path: '/issues',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/issues' | '/$'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/issues' | '/$'
id: '__root__' | '/' | '/issues' | '/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
IssuesRouteRoute: typeof IssuesRouteRoute
SplatRoute: typeof SplatRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/$': {
id: '/$'
path: '/$'
fullPath: '/$'
preLoaderRoute: typeof SplatRouteImport
parentRoute: typeof rootRouteImport
}
'/issues': {
id: '/issues'
path: '/issues'
fullPath: '/issues'
preLoaderRoute: typeof IssuesRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
IssuesRouteRoute: IssuesRouteRoute,
SplatRoute: SplatRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

12
webui/src/routes/$.tsx Normal file
View file

@ -0,0 +1,12 @@
import { createFileRoute } from '@tanstack/react-router';
import { LegacyRouteController } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/$')({
component: LegacyFallbackRouteComponent,
});
function LegacyFallbackRouteComponent() {
const { _splat } = Route.useParams();
return <LegacyRouteController pathname={`/${_splat}`} />;
}

View file

@ -0,0 +1,20 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { AppRouterContext } from '@/app/router';
import { waitForShellContext } from '@/platform/shell/bridge';
import { IssueDomainHost } from './issues/-ui/issue-domain-host';
export const Route = createRootRouteWithContext<AppRouterContext>()({
beforeLoad: async () => {
const shell = await waitForShellContext();
return { shell };
},
component: () => (
<>
<Outlet />
<IssueDomainHost />
</>
),
});

View file

@ -0,0 +1,19 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath } from '@/platform/shell/bridge';
import { LegacyRouteController } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/')({
beforeLoad: ({ context, location }) => {
if (location.pathname !== '/') return;
const { bridge } = context.shell;
throw redirect({ href: getProfileHomePath(bridge), replace: true });
},
component: IndexRouteComponent,
});
function IndexRouteComponent() {
return <LegacyRouteController pathname="/" />;
}

View file

@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { HttpResponse, http, server } from '@/test/msw';
import type { IssueRecord } from './-issues.types';
import {
createIssue,
deleteIssue,
fetchIssue,
fetchIssueCounts,
fetchIssueList,
updateIssue,
} from './-issues.api';
const counts = {
open: 4,
in_progress: 2,
resolved: 1,
dismissed: 3,
total: 10,
};
const issue: IssueRecord = {
id: 17,
profile_id: 2,
entity_type: 'track',
entity_id: '987',
category: 'wrong_metadata',
title: 'Wrong metadata',
description: 'Title is incorrect',
status: 'open',
priority: 'normal',
snapshot_data: null,
created_at: '2026-05-01T10:00:00.000Z',
updated_at: '2026-05-01T10:30:00.000Z',
};
describe('issue api', () => {
it('fetches issue counts with the profile header', async () => {
server.use(
http.get('/api/issues/counts', ({ request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('42');
return HttpResponse.json({
success: true,
counts,
});
}),
);
await expect(fetchIssueCounts(42)).resolves.toEqual(counts);
});
it('includes list filters and surfaces backend error messages', async () => {
server.use(
http.get('/api/issues', ({ request }) => {
const url = new URL(request.url);
expect(request.headers.get('X-Profile-Id')).toBe('7');
expect(url.searchParams.get('limit')).toBe('100');
expect(url.searchParams.get('status')).toBe('open');
expect(url.searchParams.get('category')).toBe('wrong_metadata');
return HttpResponse.json(
{
error: 'Issue list unavailable',
},
{ status: 500 },
);
}),
);
await expect(
fetchIssueList(7, {
status: 'open',
category: 'wrong_metadata',
}),
).rejects.toThrow('Issue list unavailable');
});
it('falls back when an issue payload is missing the record', async () => {
server.use(
http.get('/api/issues/:issueId', ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('8');
expect(params.issueId).toBe('19');
return HttpResponse.json({
success: false,
});
}),
);
await expect(fetchIssue(8, 19)).rejects.toThrow('Issue not found');
});
it('normalizes create issue payloads before posting', async () => {
server.use(
http.post('/api/issues', async ({ request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('13');
expect(request.headers.get('Content-Type')).toContain('application/json');
await expect(request.json()).resolves.toEqual({
entity_type: 'album',
entity_id: 'album-55',
category: 'wrong_cover',
title: 'Missing cover',
description: '',
priority: 'normal',
});
return HttpResponse.json({
success: true,
issue,
});
}),
);
await expect(
createIssue(13, {
entity_type: 'album',
entity_id: 'album-55',
category: 'wrong_cover',
title: 'Missing cover',
}),
).resolves.toEqual(issue);
});
it('posts issue updates to the correct endpoint', async () => {
server.use(
http.put('/api/issues/:issueId', async ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('21');
expect(params.issueId).toBe('17');
await expect(request.json()).resolves.toEqual({
status: 'resolved',
admin_response: 'Fixed upstream',
});
return HttpResponse.json({
success: true,
});
}),
);
await expect(
updateIssue(21, 17, {
status: 'resolved',
admin_response: 'Fixed upstream',
}),
).resolves.toBeUndefined();
});
it('surfaces delete errors from the server', async () => {
server.use(
http.delete('/api/issues/:issueId', ({ params, request }) => {
expect(request.headers.get('X-Profile-Id')).toBe('5');
expect(params.issueId).toBe('91');
return HttpResponse.json(
{
error: 'Cannot delete issue',
},
{ status: 403 },
);
}),
);
await expect(deleteIssue(5, 91)).rejects.toThrow('Cannot delete issue');
});
});

View file

@ -0,0 +1,154 @@
import { queryOptions, type QueryClient } from '@tanstack/react-query';
import { apiClient, readJson } from '@/app/api-client';
import type {
CreateIssuePayload,
IssueCounts,
IssueCountsResponse,
IssueDetailResponse,
IssueListResponse,
IssueRecord,
IssuesSearch,
} from './-issues.types';
const DEFAULT_LIMIT = 100;
export const ISSUES_QUERY_KEY = ['issues'] as const;
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
const headers = new Headers(extra);
headers.set('X-Profile-Id', String(profileId || 1));
return headers;
}
export async function fetchIssueCounts(profileId: number): Promise<IssueCounts> {
const payload = await readJson<IssueCountsResponse>(
apiClient.get('issues/counts', {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issue counts');
}
return payload.counts;
}
export async function fetchIssueList(
profileId: number,
search: Pick<IssuesSearch, 'status' | 'category'>,
): Promise<IssueListResponse> {
const params = new URLSearchParams();
params.set('limit', String(DEFAULT_LIMIT));
if (search.status !== 'all') {
params.set('status', search.status);
}
if (search.category !== 'all') {
params.set('category', search.category);
}
const payload = await readJson<IssueListResponse>(
apiClient.get('issues', {
headers: createIssueHeaders(profileId),
searchParams: params,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to load issues');
}
return payload;
}
export async function fetchIssue(profileId: number, issueId: number): Promise<IssueRecord> {
const payload = await readJson<IssueDetailResponse>(
apiClient.get(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success || !payload.issue) {
throw new Error(payload.error || 'Issue not found');
}
return payload.issue;
}
export async function updateIssue(
profileId: number,
issueId: number,
updates: { status?: string; admin_response?: string },
): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.put(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
json: updates,
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to update issue');
}
}
export async function createIssue(
profileId: number,
payload: CreateIssuePayload,
): Promise<IssueRecord | null> {
const response = await readJson<{
success: boolean;
issue?: IssueRecord;
error?: string;
}>(
apiClient.post('issues', {
headers: createIssueHeaders(profileId, { 'Content-Type': 'application/json' }),
json: {
entity_type: payload.entity_type,
entity_id: String(payload.entity_id),
category: payload.category,
title: payload.title,
description: payload.description || '',
priority: payload.priority || 'normal',
},
}),
);
if (!response.success) {
throw new Error(response.error || 'Failed to submit issue');
}
return response.issue ?? null;
}
export async function deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await readJson<{ success: boolean; error?: string }>(
apiClient.delete(`issues/${issueId}`, {
headers: createIssueHeaders(profileId),
}),
);
if (!payload.success) {
throw new Error(payload.error || 'Failed to delete issue');
}
}
export function issueCountsQueryOptions(profileId: number) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'counts', profileId],
queryFn: () => fetchIssueCounts(profileId),
});
}
export function issueListQueryOptions(
profileId: number,
search: Pick<IssuesSearch, 'status' | 'category'>,
) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),
});
}
export function issueDetailQueryOptions(profileId: number, issueId: number) {
return queryOptions({
queryKey: [...ISSUES_QUERY_KEY, 'detail', profileId, issueId],
queryFn: () => fetchIssue(profileId, issueId),
enabled: issueId > 0,
});
}
export function invalidateIssuesQueries(queryClient: QueryClient) {
return queryClient.invalidateQueries({ queryKey: ISSUES_QUERY_KEY });
}

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { ISSUE_CATEGORY_META } from './-issues.helpers';
import { issueSearchSchema } from './-issues.types';
describe('issueSearchSchema', () => {
it('falls back to all for unknown categories', () => {
expect(issueSearchSchema.parse({ category: 'not_real' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('preserves known categories', () => {
expect(issueSearchSchema.parse({ category: 'wrong_metadata' })).toEqual({
status: 'open',
category: 'wrong_metadata',
issueId: undefined,
});
});
it('falls back to open for unknown statuses', () => {
expect(issueSearchSchema.parse({ status: 'not_real' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('drops invalid issue ids', () => {
expect(issueSearchSchema.parse({ issueId: 'abc123' })).toEqual({
status: 'open',
category: 'all',
issueId: undefined,
});
});
it('normalizes numeric issue ids', () => {
expect(issueSearchSchema.parse({ issueId: '7' })).toEqual({
status: 'open',
category: 'all',
issueId: 7,
});
});
it('keeps the legacy category icons', () => {
expect(ISSUE_CATEGORY_META.wrong_metadata.icon).toBe('✎');
expect(ISSUE_CATEGORY_META.wrong_cover.icon).toBe('📷');
expect(ISSUE_CATEGORY_META.audio_quality.icon).toBe('🎵');
});
});

View file

@ -0,0 +1,162 @@
import {
type IssueCategory,
type IssueRecord,
type IssueSnapshot,
type IssuePriority,
type IssueStatus,
} from './-issues.types';
export const ISSUE_CATEGORY_META: Record<
IssueCategory,
{ label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> }
> = {
wrong_track: {
label: 'Wrong Track',
icon: '❌',
description: 'This file plays a different song than expected',
applies: ['track'],
},
wrong_metadata: {
label: 'Wrong Metadata',
icon: '✎',
description: 'Title, artist, year, or other tags are incorrect',
applies: ['track', 'album'],
},
wrong_cover: {
label: 'Wrong Cover Art',
icon: '📷',
description: 'The artwork is wrong or missing',
applies: ['album'],
},
wrong_artist: {
label: 'Wrong Artist',
icon: '👤',
description: 'This track is filed under the wrong artist',
applies: ['track'],
},
duplicate_tracks: {
label: 'Duplicate Tracks',
icon: '🔁',
description: 'The same track appears more than once in this album',
applies: ['album'],
},
missing_tracks: {
label: 'Missing Tracks',
icon: '❓',
description: 'Tracks that should be here are missing',
applies: ['album'],
},
audio_quality: {
label: 'Audio Quality',
icon: '🎵',
description: 'Audio has quality issues like clipping or low bitrate',
applies: ['track'],
},
wrong_album: {
label: 'Wrong Album',
icon: '💿',
description: 'This track belongs to a different album',
applies: ['track'],
},
incomplete_album: {
label: 'Incomplete Album',
icon: '⚠',
description: 'Album is partially downloaded',
applies: ['album'],
},
other: {
label: 'Other',
icon: '💬',
description: 'Any other issue not listed above',
applies: ['track', 'album', 'artist'],
},
};
export const ISSUE_STATUS_META: Record<IssueStatus, { label: string; className: string }> = {
open: { label: 'Open', className: 'is-open' },
in_progress: { label: 'In Progress', className: 'is-progress' },
resolved: { label: 'Resolved', className: 'is-resolved' },
dismissed: { label: 'Dismissed', className: 'is-dismissed' },
};
export function getIssueCategoriesForEntity(entityType: IssueRecord['entity_type']) {
return Object.entries(ISSUE_CATEGORY_META).filter(([, category]) =>
category.applies.includes(entityType),
);
}
export function createDefaultIssueTitle(category: string, entityName: string): string {
const label = getIssueCategoryMeta(category)?.label || 'Issue';
return `${label}: ${entityName || 'Unknown'}`;
}
export function getIssueCategoryMeta(category: string) {
return ISSUE_CATEGORY_META[category as IssueCategory];
}
export function getIssueStatusMeta(status: string) {
return ISSUE_STATUS_META[status as IssueStatus];
}
export function parseSnapshot(snapshot: IssueRecord['snapshot_data']): IssueSnapshot {
if (!snapshot) {
return {};
}
if (typeof snapshot === 'string') {
try {
return JSON.parse(snapshot) as IssueSnapshot;
} catch {
return {};
}
}
return snapshot;
}
export function getEntityLabel(entityType: IssueRecord['entity_type']): string {
return entityType === 'track' ? 'Track' : entityType === 'album' ? 'Album' : 'Artist';
}
export function getEntityName(issue: IssueRecord, snapshot: IssueSnapshot): string {
const entityLabel = getEntityLabel(issue.entity_type);
return String(snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`);
}
export function getEntityDetails(issue: IssueRecord, snapshot: IssueSnapshot): string[] {
const details: string[] = [];
if (issue.entity_type === 'track') {
if (snapshot.artist_name) details.push(String(snapshot.artist_name));
if (snapshot.album_title) details.push(String(snapshot.album_title));
} else if (issue.entity_type === 'album') {
if (snapshot.artist_name) details.push(String(snapshot.artist_name));
} else if (issue.entity_type === 'artist' && snapshot.name) {
details.push(String(snapshot.name));
}
return details;
}
export function getIssueArtwork(snapshot: IssueSnapshot): string {
return String(snapshot.thumb_url || snapshot.album_thumb || snapshot.artist_thumb || '');
}
export function formatIssueDate(value?: string): string {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatStatusLabel(status: string): string {
return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
}
export function getPriorityClassName(priority: string): IssuePriority {
if (priority === 'high') return 'high';
if (priority === 'low') return 'low';
return 'normal';
}

View file

@ -0,0 +1,164 @@
import { z } from 'zod';
export const ISSUE_ENTITY_TYPE_VALUES = ['track', 'album', 'artist'] as const;
export type IssueEntityType = (typeof ISSUE_ENTITY_TYPE_VALUES)[number];
export const ISSUE_CATEGORY_VALUES = [
'wrong_track',
'wrong_metadata',
'wrong_cover',
'wrong_artist',
'duplicate_tracks',
'missing_tracks',
'audio_quality',
'wrong_album',
'incomplete_album',
'other',
] as const;
export type IssueCategory = (typeof ISSUE_CATEGORY_VALUES)[number];
export const ISSUE_STATUS_VALUES = ['open', 'in_progress', 'resolved', 'dismissed'] as const;
export type IssueStatus = (typeof ISSUE_STATUS_VALUES)[number];
export const ISSUE_PRIORITY_VALUES = ['low', 'normal', 'high'] as const;
export type IssuePriority = (typeof ISSUE_PRIORITY_VALUES)[number];
export const ISSUE_SEARCH_STATUS_VALUES = [
'open',
'all',
'in_progress',
'resolved',
'dismissed',
] as const;
export const ISSUE_SEARCH_CATEGORY_VALUES = ['all', ...ISSUE_CATEGORY_VALUES] as const;
export const issueSearchSchema = z.object({
status: z.enum(ISSUE_SEARCH_STATUS_VALUES).default('open').catch('open'),
category: z.enum(ISSUE_SEARCH_CATEGORY_VALUES).default('all').catch('all'),
issueId: z.coerce.number().int().positive().optional().catch(undefined),
});
export type IssuesSearch = z.infer<typeof issueSearchSchema>;
export interface IssueTrackRow extends Record<string, unknown> {
bitrate?: string | number;
disc_number?: string | number;
duration?: string | number;
format?: string;
id?: string | number;
title?: string;
track_number?: string | number;
}
export interface IssueSnapshot {
[key: string]: unknown;
album_track_count?: string | number;
bitrate?: string | number;
bpm?: string | number;
disc_number?: string | number;
duration?: string | number;
file_path?: string;
format?: string;
genres?: string[];
label?: string;
name?: string;
record_type?: string;
title?: string;
track_count?: string | number;
tracks?: IssueTrackRow[];
track_number?: string | number;
year?: string | number;
artist_name?: string;
album_title?: string;
thumb_url?: string;
artist_thumb?: string;
album_thumb?: string;
spotify_album_id?: string;
spotify_artist_id?: string;
spotify_track_id?: string;
artist_id?: string | number;
album_id?: string | number;
quality?: string;
artist_musicbrainz_id?: string;
musicbrainz_release_id?: string;
musicbrainz_recording_id?: string;
artist_deezer_id?: string;
album_deezer_id?: string;
track_deezer_id?: string;
artist_tidal_id?: string;
album_tidal_id?: string;
artist_qobuz_id?: string | number;
album_qobuz_id?: string | number;
}
export interface IssueRecord {
id: number;
profile_id: number;
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
description?: string | null;
status: string;
priority: string;
snapshot_data: IssueSnapshot | string | null;
created_at?: string;
updated_at?: string;
resolved_at?: string | null;
resolved_by?: number | null;
admin_response?: string | null;
reporter_name?: string | null;
reporter_color?: string | null;
reporter_avatar?: string | null;
}
export interface IssueCounts {
open: number;
in_progress: number;
resolved: number;
dismissed: number;
total: number;
}
export interface IssueListResponse {
success: boolean;
issues: IssueRecord[];
total: number;
error?: string;
}
export interface IssueDetailResponse {
success: boolean;
issue?: IssueRecord;
error?: string;
}
export interface IssueCountsResponse {
success: boolean;
counts: IssueCounts;
error?: string;
}
export interface CreateIssuePayload {
entity_type: IssueEntityType;
entity_id: string;
category: string;
title: string;
description?: string;
priority?: IssuePriority;
}
export interface IssueReportPayload {
entityType: IssueEntityType;
entityId: string | number;
entityName: string;
artistName?: string;
albumTitle?: string;
}
export interface IssueDomainBridge {
openReportIssue: (payload: IssueReportPayload) => void;
refresh: () => void;
closeReportIssue?: () => void;
}

View file

@ -0,0 +1,273 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
function createResponse(body: unknown, ok = true, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
function renderIssuesRoute(initialEntries = ['/issues']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return {
history,
router,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
const workflowActions = {
openDownloadMissingAlbum: vi.fn(),
openAddToWishlistAlbum: vi.fn(),
};
describe('issues route', () => {
beforeEach(() => {
workflowActions.openDownloadMissingAlbum.mockReset();
workflowActions.openAddToWishlistAlbum.mockReset();
window.SoulSyncWebShellBridge = createShellBridge();
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes('/api/issues/counts')) {
return createResponse({
success: true,
counts: {
open: 2,
in_progress: 1,
resolved: 0,
dismissed: 0,
total: 3,
},
});
}
if (url.includes('/api/issues?')) {
return createResponse({
success: true,
total: 1,
issues: [
{
id: 7,
profile_id: 2,
entity_type: 'album',
entity_id: '15',
category: 'wrong_metadata',
title: 'Bad tags',
description: 'Album title is wrong',
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
],
});
}
if (url.includes('/api/issues/7')) {
return createResponse({
success: true,
issue: {
id: 7,
profile_id: 2,
entity_type: 'album',
entity_id: '15',
category: 'wrong_metadata',
title: 'Bad tags',
description: 'Album title is wrong',
status: 'open',
priority: 'normal',
snapshot_data: {
title: 'Album Name',
artist_name: 'Artist',
thumb_url: 'https://example.com/thumb.jpg',
spotify_album_id: 'abc123',
track_number: 1,
duration: 245,
format: 'FLAC',
bitrate: 1411,
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
});
}
if (url.includes('/api/spotify/album/abc123')) {
return createResponse({
id: 'abc123',
name: 'Album Name',
album_type: 'album',
images: [{ url: 'https://example.com/thumb.jpg' }],
total_tracks: 1,
artists: [{ name: 'Artist' }],
tracks: [{ id: 'track-1', name: 'Track 1' }],
});
}
return createResponse({ success: true });
}) as unknown as typeof fetch,
);
vi.stubGlobal('SoulSyncWorkflowActions', workflowActions);
vi.stubGlobal('showToast', vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
});
it('renders stats and list items through the app router', async () => {
renderIssuesRoute();
await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2'));
expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags');
});
it('loads the detail modal from the route search state', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(await screen.findByTitle('Spotify Album')).toHaveAttribute(
'href',
'https://open.spotify.com/album/abc123',
);
});
it('stores filters in route search state', async () => {
renderIssuesRoute();
const status = await screen.findByRole('combobox', { name: /status/i });
fireEvent.change(status, { target: { value: 'resolved' } });
await waitFor(() => expect(status).toHaveValue('resolved'));
});
it('opens and closes the detail modal', async () => {
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('closes the detail modal with Escape', async () => {
const { history } = renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
await waitFor(() => expect(history.location.search).toContain('issueId=7'));
fireEvent.keyDown(document, { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await waitFor(() => expect(history.location.search).toBe('?status=open&category=all'));
});
it('focuses the detail modal close button on open', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
const closeButton = await screen.findByRole('button', {
name: /close issue detail/i,
});
await waitFor(() => expect(closeButton).toHaveFocus());
});
it('invokes the shared workflow adapter for admin downloads', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled());
expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith(
expect.objectContaining({
virtualPlaylistId: 'issue_download_abc123',
playlistName: '[Artist] Album Name',
}),
);
});
it('opens the global React issue composer through the domain bridge', async () => {
const fetchMock = vi.mocked(fetch);
renderIssuesRoute();
await waitFor(() => expect(window.SoulSyncIssueDomain).toBeDefined());
act(() => {
window.SoulSyncIssueDomain?.openReportIssue({
entityType: 'album',
entityId: 15,
entityName: 'Album Name',
artistName: 'Artist',
});
});
fireEvent.click(await screen.findByRole('button', { name: /wrong cover art/i }));
const titleInput = screen.getByLabelText(/title/i);
const descriptionInput = screen.getByLabelText(/details/i);
const submitButton = screen.getByRole('button', { name: /submit issue/i });
const form = submitButton.closest('form');
expect(titleInput).toHaveValue('Wrong Cover Art: Album Name');
fireEvent.change(titleInput, { target: { value: '' } });
expect(submitButton).toBeDisabled();
fireEvent.submit(form!);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Please provide a title for the issue');
});
fireEvent.change(titleInput, { target: { value: 'Custom report title' } });
fireEvent.blur(titleInput);
fireEvent.change(descriptionInput, {
target: { value: 'Detailed reproduction notes' },
});
fireEvent.click(screen.getByRole('button', { name: /high/i }));
fireEvent.click(screen.getByRole('button', { name: /wrong metadata/i }));
expect(titleInput).toHaveValue('Custom report title');
expect(descriptionInput).toHaveValue('Detailed reproduction notes');
expect(screen.getByRole('button', { name: /high/i })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: /submit issue/i }));
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([request]) => request instanceof Request && request.method === 'POST',
),
).toBe(true);
});
});
it('does not render track details for album issues', async () => {
renderIssuesRoute(['/issues?issueId=7']);
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
expect(screen.queryByText('Track Details')).not.toBeInTheDocument();
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,736 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import { Button } from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers';
import {
launchAlbumDownloadWorkflow,
launchAlbumWishlistWorkflow,
} from '@/platform/workflows/album-workflows';
import type { IssueRecord, IssueTrackRow } from '../-issues.types';
import { deleteIssue, issueDetailQueryOptions, updateIssue } from '../-issues.api';
import {
formatIssueDate,
formatStatusLabel,
getIssueArtwork,
getPriorityClassName,
getIssueCategoryMeta,
ISSUE_CATEGORY_META,
parseSnapshot,
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
export function IssueDetailModal({
issueId,
onClose,
onMutationSuccess,
}: {
issueId?: number;
onClose: () => void;
onMutationSuccess: () => void;
}) {
const { isAdmin, profileId } = useProfile();
const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, issueId ?? 0),
enabled: issueId != null,
});
const issue = selectedIssueQuery.data ?? null;
const queryError = selectedIssueQuery.error;
const queryLoading = selectedIssueQuery.isLoading;
const [adminResponse, setAdminResponse] = useState('');
const isOpen = Boolean(issueId || queryLoading || queryError);
useEffect(() => {
setAdminResponse(issue?.admin_response || '');
}, [issue?.admin_response, issue?.id]);
const updateMutation = useMutation({
mutationFn: async (payload: { issueId: number; status: string; adminResponse: string }) => {
await updateIssue(profileId, payload.issueId, {
status: payload.status,
admin_response: payload.adminResponse,
});
},
onSuccess: async () => {
onMutationSuccess();
},
});
const deleteMutation = useMutation({
mutationFn: async (issueId: number) => {
await deleteIssue(profileId, issueId);
},
onSuccess: async () => {
onMutationSuccess();
},
});
const downloadWorkflowMutation = useMutation({
mutationFn: launchAlbumDownloadWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const wishlistWorkflowMutation = useMutation({
mutationFn: launchAlbumWishlistWorkflow,
onError: notifyWorkflowError,
onSuccess: onClose,
});
const statusButtons = useMemo(() => {
if (!issue) return null;
if (isAdmin) {
if (issue.status === 'open' || issue.status === 'in_progress') {
return (
<>
{issue.status === 'open' && (
<Button
className={styles.modalButtonProgress}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'in_progress',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Mark In Progress
</Button>
)}
<Button
className={styles.modalButtonResolve}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'resolved',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Resolve
</Button>
<Button
className={styles.modalButtonDismiss}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'dismissed',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Dismiss
</Button>
</>
);
}
return (
<Button
className={styles.modalButtonReopen}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'open',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Reopen
</Button>
);
}
if (issue.status === 'open') {
return (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Withdraw this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Withdraw
</Button>
);
}
return null;
}, [adminResponse, deleteMutation, isAdmin, issue, updateMutation]);
if (!issue && !queryLoading && !queryError) {
return null;
}
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryMeta = issue ? getIssueCategoryMeta(issue.category) : undefined;
const issueCategoryLabel = issue
? `${issueCategoryMeta?.icon || ''} ${issueCategoryMeta?.label || ISSUE_CATEGORY_META.other.label}`.trim()
: '';
const externalLinks = getExternalLinks(snapshot);
const trackMetaItems = getTrackMetaItems(snapshot);
const trackRows = Array.isArray(snapshot.tracks) ? snapshot.tracks : [];
const priorityLevel = issue ? getPriorityClassName(issue.priority) : 'normal';
const albumMetaParts = issue ? getAlbumMetaParts(issue, snapshot) : [];
const genreTags = Array.isArray(snapshot.genres) ? snapshot.genres.slice(0, 5) : [];
const albumWorkflowInput = {
spotifyAlbumId: String(snapshot.spotify_album_id || ''),
artistName: String(snapshot.artist_name || ''),
albumName: String(snapshot.album_title || snapshot.title || ''),
source: 'issue',
};
return (
<DialogFrame
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.issueDetailDialog}
>
<DialogHeader
title={issue ? `Issue #${issue.id}` : 'Issue details'}
closeLabel="Close issue detail"
/>
<DialogBody>{renderIssueDetailContent()}</DialogBody>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Close
</Button>
{issue && (
<>
{statusButtons}
{isAdmin && (
<Button
className={styles.modalButtonDelete}
type="button"
onClick={() => {
if (window.confirm('Delete this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Delete
</Button>
)}
</>
)}
</DialogFooter>
</DialogFrame>
);
function renderIssueDetailContent() {
if (queryLoading) {
return (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
</div>
);
}
if (queryError) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{queryError instanceof Error ? queryError.message : 'Unknown error'}
</div>
</div>
);
}
if (!issue) {
return null;
}
return (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
<Show when={issue.entity_type === 'artist' && issueArtwork}>
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
</Show>
<Show
when={issueArtwork}
fallback={
<div className={styles.issueHeroAlbumPlaceholder}>
{issueCategoryMeta?.icon || ISSUE_CATEGORY_META.other.icon}
</div>
}
>
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
</Show>
</div>
<div className={styles.issueHeroInfo}>
<Show when={issue.entity_type !== 'artist' && snapshot.artist_name}>
{(artistName) => <div className={styles.issueHeroArtist}>{String(artistName)}</div>}
</Show>
<div className={styles.issueHeroAlbum}>
{String(
issue.entity_type === 'artist'
? snapshot.name || issue.title
: snapshot.album_title || snapshot.title || issue.title,
)}
</div>
<Show when={issue.entity_type === 'track'}>
<div className={styles.issueHeroTrackName}> {issue.title}</div>
</Show>
<Show when={issue.entity_type !== 'artist' && albumMetaParts.length > 0}>
<div className={styles.issueHeroMeta}>{albumMetaParts.join(' - ')}</div>
</Show>
<Show when={genreTags.length}>
<div className={styles.issueHeroGenres}>
{genreTags.map((genre) => (
<span className={styles.issueHeroGenreTag} key={String(genre)}>
{String(genre)}
</span>
))}
</div>
</Show>
<Show when={externalLinks.length}>
<div className={styles.issueExternalLinks}>
{externalLinks.map((link) => (
<Show
key={`${link.service}-${link.type}-${link.label}`}
when={link.url}
fallback={
<span
className={`${styles.issueExternalLink} ${styles[link.className]}`}
title={`${link.service} ${link.type}: ${link.id}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</span>
}
>
<a
className={`${styles.issueExternalLink} ${styles[link.className]}`}
href={link.url}
target="_blank"
rel="noreferrer"
title={`${link.service} ${link.type}`}
>
<span className={styles.issueExternalLinkService}>{link.service}</span>
<span className={styles.issueExternalLinkType}>{link.type}</span>
</a>
</Show>
))}
</div>
</Show>
</div>
</div>
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<span
className={`${styles.issuePriorityDot} ${getPriorityDotClassName(priorityLevel)}`}
/>
<span className={`${styles.issueStatusBadge} ${getStatusClassName(issue.status)}`}>
{formatStatusLabel(issue.status)}
</span>
<span className={styles.issueDetailCategory}>{issueCategoryLabel}</span>
</div>
<div className={styles.issueDetailInfoRight}>
<span className={styles.issueDetailDate}>
Reported {formatIssueDate(issue.created_at)}
</span>
<Show when={issue.resolved_at}>
{(resolvedAt) => (
<span className={styles.issueDetailDate}>
Resolved {formatIssueDate(resolvedAt)}
</span>
)}
</Show>
<Show when={isAdmin && issue.reporter_name}>
{(reporterName) => (
<span className={styles.issueDetailProfile}>by {reporterName}</span>
)}
</Show>
</div>
</div>
<Show when={issue.entity_type !== 'artist' && isAdmin}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}>
<Button
className={styles.issueActionDownload}
type="button"
disabled={downloadWorkflowMutation.isPending}
onClick={() => downloadWorkflowMutation.mutate(albumWorkflowInput)}
>
{downloadWorkflowMutation.isPending ? 'Loading...' : 'Download Album'}
</Button>
<Button
className={styles.issueActionWishlist}
type="button"
disabled={wishlistWorkflowMutation.isPending}
onClick={() => wishlistWorkflowMutation.mutate(albumWorkflowInput)}
>
{wishlistWorkflowMutation.isPending ? 'Loading...' : 'Add to Wishlist'}
</Button>
</div>
</div>
</Show>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc}
>
{issue.description || 'No additional details provided'}
</div>
</div>
<Show when={issue.entity_type === 'track' && trackMetaItems.length > 0}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Track Details</div>
<div className={styles.issueDetailMetaGrid}>
{trackMetaItems.map((item) => (
<div className={styles.issueMetaItem} key={item.label}>
<span className={styles.issueMetaIcon}>{item.icon}</span>
<span className={styles.issueMetaLabel}>{item.label}</span>
<span className={styles.issueMetaValue}>{item.value}</span>
</div>
))}
</div>
</div>
</Show>
<Show when={snapshot.file_path}>
{(filePath) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>File Path</div>
<div className={styles.issueDetailFilepath}>{String(filePath)}</div>
</div>
)}
</Show>
<Show when={trackRows.length}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>
Track Listing{' '}
<span className={styles.issueDetailSectionCount}>{trackRows.length} tracks</span>
</div>
<div className={styles.issueDetailTracklist}>{renderTrackListing(trackRows)}</div>
</div>
</Show>
<Show when={isAdmin}>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<textarea
className={styles.issueDetailResponseTextarea}
id="issue-detail-response-input"
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
rows={3}
/>
</div>
</Show>
<Show when={!isAdmin && issue.admin_response}>
{(response) => (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<div className={styles.issueDetailAdminResponse}>{response}</div>
</div>
)}
</Show>
</>
);
}
}
function renderTrackListing(trackRows: IssueTrackRow[]) {
const nodes: ReactNode[] = [];
let lastDisc: number | null = null;
const hasMultiDisc = trackRows.some((track) => Number(track.disc_number || 1) > 1);
trackRows.forEach((track, index) => {
const disc = Number(track.disc_number || 1);
if (hasMultiDisc && disc !== lastDisc) {
nodes.push(
<div className={styles.issueDetailTracklistDisc} key={`disc-${disc}-${index}`}>
Disc {disc}
</div>,
);
lastDisc = disc;
}
const format = String(track.format || '').toUpperCase();
const bitrateValue = typeof track.bitrate === 'number' ? track.bitrate : Number(track.bitrate);
const bitrate = Number.isFinite(bitrateValue) && bitrateValue > 0 ? `${bitrateValue}k` : '';
const duration = formatDuration(track.duration);
const formatClassName = getTrackFormatClassName(format);
const bitrateClassName = getTrackBitrateClassName(bitrateValue, format);
nodes.push(
<div
className={styles.issueDetailTracklistRow}
key={String(track.id || `${track.title}-${index}`)}
>
<span className={styles.issueDetailTracklistNum}>{String(track.track_number || '-')}</span>
<span className={styles.issueDetailTracklistTitle}>{String(track.title || 'Unknown')}</span>
<span className={styles.issueDetailTracklistDur}>{duration}</span>
<span className={styles.issueDetailTracklistMeta}>
<Show when={format}>
<span className={`${styles.issueTrackBadge} ${formatClassName}`}>{format}</span>
</Show>
<Show when={bitrate}>
<span className={`${styles.issueTrackBadge} ${bitrateClassName}`}>{bitrate}</span>
</Show>
</span>
</div>,
);
});
return nodes;
}
function getPriorityDotClassName(priority: string) {
if (priority === 'high') return styles.issuePriorityHigh;
if (priority === 'low') return styles.issuePriorityLow;
return styles.issuePriorityNormal;
}
function getTrackFormatClassName(format: string) {
const lower = format.toLowerCase();
if (lower === 'flac') return styles.issueTrackBadgeFlac;
if (lower === 'mp3') return styles.issueTrackBadgeMp3;
return styles.issueTrackBadgeOther;
}
function getTrackBitrateClassName(bitrate: number, format: string) {
const lower = format.toLowerCase();
if (!Number.isFinite(bitrate) || bitrate <= 0) return styles.issueTrackBadgeOther;
if (bitrate >= 320 || lower === 'flac') return styles.issueTrackBadgeHigh;
if (bitrate >= 192) return styles.issueTrackBadgeMedium;
return styles.issueTrackBadgeLow;
}
function getStatusClassName(status: string) {
if (status === 'in_progress') return styles.issueStatusProgress;
if (status === 'resolved') return styles.issueStatusResolved;
if (status === 'dismissed') return styles.issueStatusDismissed;
return styles.issueStatusOpen;
}
function notifyWorkflowError(error: unknown) {
const message = error instanceof Error ? error.message : 'Workflow failed';
window.showToast?.(message, 'error');
}
function formatDuration(value: unknown): string {
const duration = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(duration) || duration <= 0) return '';
const seconds = duration > 10000 ? Math.floor(duration / 1000) : Math.floor(duration);
const minutes = Math.floor(seconds / 60);
const remaining = seconds % 60;
return `${minutes}:${String(remaining).padStart(2, '0')}`;
}
function getExternalLinks(snapshot: ReturnType<typeof parseSnapshot>) {
const links: Array<{
className:
| 'issueExternalLinkSpotify'
| 'issueExternalLinkMusicBrainz'
| 'issueExternalLinkDeezer'
| 'issueExternalLinkTidal'
| 'issueExternalLinkQobuz';
id?: string | number;
label: string;
service: string;
type: string;
url?: string;
}> = [];
if (snapshot.spotify_artist_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Artist',
service: 'Spotify',
type: 'Artist',
url: `https://open.spotify.com/artist/${snapshot.spotify_artist_id}`,
});
}
if (snapshot.spotify_album_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Album',
service: 'Spotify',
type: 'Album',
url: `https://open.spotify.com/album/${snapshot.spotify_album_id}`,
});
}
if (snapshot.spotify_track_id) {
links.push({
className: 'issueExternalLinkSpotify',
label: 'Spotify Track',
service: 'Spotify',
type: 'Track',
url: `https://open.spotify.com/track/${snapshot.spotify_track_id}`,
});
}
if (snapshot.artist_musicbrainz_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Artist',
service: 'MusicBrainz',
type: 'Artist',
url: `https://musicbrainz.org/artist/${snapshot.artist_musicbrainz_id}`,
});
}
if (snapshot.musicbrainz_release_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Release',
service: 'MusicBrainz',
type: 'Release',
url: `https://musicbrainz.org/release/${snapshot.musicbrainz_release_id}`,
});
}
if (snapshot.musicbrainz_recording_id) {
links.push({
className: 'issueExternalLinkMusicBrainz',
label: 'MusicBrainz Recording',
service: 'MusicBrainz',
type: 'Recording',
url: `https://musicbrainz.org/recording/${snapshot.musicbrainz_recording_id}`,
});
}
if (snapshot.artist_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Artist',
service: 'Deezer',
type: 'Artist',
url: `https://www.deezer.com/artist/${snapshot.artist_deezer_id}`,
});
}
if (snapshot.album_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Album',
service: 'Deezer',
type: 'Album',
url: `https://www.deezer.com/album/${snapshot.album_deezer_id}`,
});
}
if (snapshot.track_deezer_id) {
links.push({
className: 'issueExternalLinkDeezer',
label: 'Deezer Track',
service: 'Deezer',
type: 'Track',
url: `https://www.deezer.com/track/${snapshot.track_deezer_id}`,
});
}
if (snapshot.artist_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Artist',
service: 'Tidal',
type: 'Artist',
url: `https://listen.tidal.com/artist/${snapshot.artist_tidal_id}`,
});
}
if (snapshot.album_tidal_id) {
links.push({
className: 'issueExternalLinkTidal',
label: 'Tidal Album',
service: 'Tidal',
type: 'Album',
url: `https://listen.tidal.com/album/${snapshot.album_tidal_id}`,
});
}
if (snapshot.artist_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.artist_qobuz_id,
label: 'Qobuz Artist',
service: 'Qobuz',
type: 'Artist',
});
}
if (snapshot.album_qobuz_id) {
links.push({
className: 'issueExternalLinkQobuz',
id: snapshot.album_qobuz_id,
label: 'Qobuz Album',
service: 'Qobuz',
type: 'Album',
});
}
return links;
}
function getAlbumMetaParts(
issue: IssueRecord,
snapshot: ReturnType<typeof parseSnapshot>,
): string[] {
if (issue.entity_type === 'artist') return [];
const parts: string[] = [];
if (snapshot.year) parts.push(String(snapshot.year));
if (snapshot.record_type) {
const recordType = String(snapshot.record_type);
parts.push(recordType.charAt(0).toUpperCase() + recordType.slice(1));
}
const trackCount =
issue.entity_type === 'album' ? snapshot.track_count : snapshot.album_track_count;
if (trackCount) parts.push(`${trackCount} tracks`);
if (snapshot.label) parts.push(String(snapshot.label));
return parts;
}
function getTrackMetaItems(snapshot: ReturnType<typeof parseSnapshot>) {
const items: Array<{ icon: string; label: string; value: string }> = [];
if (snapshot.track_number) {
items.push({
icon: '#',
label: 'Track',
value: String(snapshot.track_number),
});
}
const duration = formatDuration(snapshot.duration);
if (duration) items.push({ icon: 'T', label: 'Duration', value: duration });
if (snapshot.format) items.push({ icon: 'F', label: 'Format', value: String(snapshot.format) });
if (snapshot.bitrate)
items.push({
icon: 'B',
label: 'Bitrate',
value: `${snapshot.bitrate} kbps`,
});
if (snapshot.bpm) items.push({ icon: 'M', label: 'BPM', value: String(snapshot.bpm) });
if (snapshot.quality)
items.push({
icon: 'Q',
label: 'Quality',
value: String(snapshot.quality),
});
return items;
}

View file

@ -0,0 +1,378 @@
import { useForm } from '@tanstack/react-form';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { z } from 'zod';
import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog';
import {
Button,
FormError,
FormField,
OptionButton,
OptionButtonGroup,
OptionCard,
OptionCardGroup,
TextArea,
TextInput,
} from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile } from '@/platform/shell/route-controllers';
import type { IssueReportPayload } from '../-issues.types';
import { createIssue, issueCountsQueryOptions, invalidateIssuesQueries } from '../-issues.api';
import {
createDefaultIssueTitle,
getIssueCategoriesForEntity,
getEntityLabel,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_PRIORITY_VALUES } from '../-issues.types';
import styles from './issue-detail-modal.module.css';
export function IssueDomainHost() {
const queryClient = useQueryClient();
const profile = useProfile();
const [reportPayload, setReportPayload] = useState<IssueReportPayload | null>(null);
const profileId = profile.profileId;
const refreshIssues = useCallback(() => {
void invalidateIssuesQueries(queryClient);
}, [queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
});
useEffect(() => {
if (countsQuery.data) {
updateBadge(countsQuery.data.open || 0);
}
}, [countsQuery.data]);
useEffect(() => {
window.SoulSyncIssueDomain = {
openReportIssue(payload) {
setReportPayload(payload);
},
closeReportIssue() {
setReportPayload(null);
},
refresh: refreshIssues,
};
return () => {
if (window.SoulSyncIssueDomain?.openReportIssue) {
window.SoulSyncIssueDomain = undefined;
}
};
}, [queryClient]);
return (
<Show when={reportPayload}>
{(payload) => (
<ReportIssueModal
key={`${payload.entityType}:${payload.entityId}`}
payload={payload}
profileId={profileId}
onClose={() => setReportPayload(null)}
onSubmitted={() => {
setReportPayload(null);
refreshIssues();
}}
/>
)}
</Show>
);
}
function ReportIssueModal({
onClose,
onSubmitted,
payload,
profileId,
}: {
onClose: () => void;
onSubmitted: () => void;
payload: IssueReportPayload;
profileId: number;
}) {
const categories = useMemo(
() => getIssueCategoriesForEntity(payload.entityType),
[payload.entityType],
);
const createMutation = useMutation({
mutationFn: async (values: ReportIssueFormValues) => {
await createIssue(profileId, {
entity_type: payload.entityType,
entity_id: String(payload.entityId),
category: values.category,
title: values.title,
description: values.description,
priority: values.priority,
});
},
onSuccess: () => {
notify('Issue reported successfully', 'success');
onSubmitted();
},
});
const form = useForm({
defaultValues: DEFAULT_REPORT_ISSUE_VALUES,
validators: {
onSubmit: ({ value }) => validateReportIssueForm(profileId, value),
},
onSubmit: async ({ value, formApi }) => {
const normalizedValues = normalizeReportIssueFormValues(value);
createMutation.reset();
formApi.setErrorMap({ onSubmit: undefined });
try {
await createMutation.mutateAsync(normalizedValues);
} catch (mutationError) {
const message =
mutationError instanceof Error ? mutationError.message : 'Failed to submit issue';
formApi.setErrorMap({ onSubmit: message });
notify(message, 'error');
throw mutationError;
}
},
});
return (
<DialogFrame
open={true}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onClose();
}
}}
className={styles.reportIssueDialog}
>
<DialogHeader
title={`Report Issue - ${getEntityLabel(payload.entityType)}`}
closeLabel="Close report issue modal"
/>
<DialogBody>
<form
className={styles.reportIssueForm}
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit().catch(() => undefined);
}}
>
<div className={styles.reportIssueEntityInfo}>
<div className={styles.reportIssueEntityName}>{payload.entityName}</div>
<Show when={payload.artistName}>
{(artistName) => (
<div className={styles.reportIssueEntityArtist}>
{artistName}
<Show when={payload.albumTitle}>{(albumTitle) => ` - ${albumTitle}`}</Show>
</div>
)}
</Show>
</div>
<FormField
label="What's the problem?"
helperText="Pick the closest match for the report."
>
<form.Field name="category">
{(field) => (
<OptionCardGroup>
{categories.map(([category, meta]) => (
<OptionCard
key={category}
description={meta.description}
icon={meta.icon}
onClick={() => {
field.handleChange(category);
createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
if (!form.getFieldMeta('title')?.isTouched) {
form.setFieldValue(
'title',
createDefaultIssueTitle(category, payload.entityName),
{ dontUpdateMeta: true },
);
}
}}
selected={field.state.value === category}
title={meta.label}
/>
))}
</OptionCardGroup>
)}
</form.Field>
</FormField>
<form.Subscribe selector={(state) => state.values.category}>
{(selectedCategory) => (
<Show when={selectedCategory}>
<>
<form.Field name="title">
{(field) => (
<FormField
helperText="A short summary that makes the problem obvious."
htmlFor="report-issue-title-input"
label="Title"
>
<TextInput
id="report-issue-title-input"
maxLength={200}
onBlur={field.handleBlur}
onChange={(event) => {
field.handleChange(event.target.value);
createMutation.reset();
form.setErrorMap({ onSubmit: undefined });
}}
placeholder="Brief summary of the issue..."
value={field.state.value}
/>
</FormField>
)}
</form.Field>
<form.Field name="description">
{(field) => (
<FormField
helperText="Include any details that will help triage the issue."
htmlFor="report-issue-desc-input"
label="Details"
>
<TextArea
id="report-issue-desc-input"
maxLength={2000}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
placeholder="Provide more details about what's wrong..."
rows={4}
value={field.state.value}
/>
</FormField>
)}
</form.Field>
<form.Field name="priority">
{(field) => (
<FormField
helperText="Set the urgency if this needs faster attention."
label="Priority"
>
<OptionButtonGroup>
{ISSUE_PRIORITY_VALUES.map((priority) => (
<OptionButton
key={priority}
onClick={() => field.handleChange(priority)}
selected={field.state.value === priority}
>
{priority[0].toUpperCase()}
{priority.slice(1)}
</OptionButton>
))}
</OptionButtonGroup>
</FormField>
)}
</form.Field>
</>
</Show>
)}
</form.Subscribe>
<form.Subscribe selector={(state) => state.errors}>
{(errors) => {
const error = getReportIssueFormError(errors);
return <FormError message={error} />;
}}
</form.Subscribe>
<DialogFooter>
<Button className={styles.modalButtonSecondary} type="button" onClick={onClose}>
Cancel
</Button>
<form.Subscribe
selector={(state) => ({
category: state.values.category,
isSubmitting: state.isSubmitting,
title: state.values.title,
})}
>
{(state) => {
const isSubmitting = state.isSubmitting || createMutation.isPending;
return (
<Button
className={styles.modalButtonPrimary}
type="submit"
disabled={!state.category || !state.title.trim() || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Issue'}
</Button>
);
}}
</form.Subscribe>
</DialogFooter>
</form>
</DialogBody>
</DialogFrame>
);
}
const DEFAULT_REPORT_ISSUE_VALUES: ReportIssueFormValues = {
category: '',
description: '',
priority: 'normal',
title: '',
};
const reportIssueFormSchema = z.object({
category: z
.string()
.trim()
.pipe(z.enum(ISSUE_CATEGORY_VALUES, { error: 'Please select an issue category' })),
description: z.string().trim(),
priority: z.enum(ISSUE_PRIORITY_VALUES),
title: z.string().trim().min(1, 'Please provide a title for the issue'),
});
type ReportIssueFormValues = z.input<typeof reportIssueFormSchema>;
type NormalizedReportIssueFormValues = z.output<typeof reportIssueFormSchema>;
function notify(message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') {
window.showToast?.(message, type);
}
function updateBadge(openCount: number) {
const badge = document.getElementById('issues-nav-badge');
if (!badge) return;
badge.textContent = String(openCount || 0);
badge.classList.toggle('hidden', !openCount);
}
function normalizeReportIssueFormValues(
values: ReportIssueFormValues,
): NormalizedReportIssueFormValues {
return reportIssueFormSchema.parse(values);
}
function validateReportIssueForm(
profileId: number,
values: ReportIssueFormValues,
): string | undefined {
if (!profileId) return 'Profile is still loading';
const result = reportIssueFormSchema.safeParse(values);
if (result.success) return undefined;
return result.error.issues[0]?.message || 'Unable to submit this issue';
}
function getReportIssueFormError(errors: Array<unknown>): string {
const error = errors.find(Boolean);
if (!error) return '';
if (typeof error === 'string') return error;
if (error instanceof Error) return error.message;
if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') {
return String(error);
}
return 'Unable to submit this issue';
}

View file

@ -0,0 +1,725 @@
.issuesContainer {
max-width: 1000px;
margin: 0 auto;
padding: 24px 20px;
color: #fff;
}
.issuesHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
}
.issuesHeaderLeft {
flex: 1;
min-width: 200px;
}
.issuesTitle {
font-size: 24px;
font-weight: 600;
color: #fff;
margin: 0 0 4px;
}
.issuesSubtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.5);
margin: 0;
}
.issuesHeaderRight {
display: flex;
align-items: center;
gap: 10px;
}
.issuesFilters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.issuesStats {
display: flex;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.issuesStatCard {
flex: 1;
min-width: 100px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 14px 16px;
text-align: center;
transition: border-color 0.2s;
}
.issuesStatNumber {
font-size: 22px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.issuesStatLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
.issuesStatOpen {
border-left: 3px solid #ffa500;
}
.issuesStatProgress {
border-left: 3px solid #4da6ff;
}
.issuesStatResolved {
border-left: 3px solid #4ade80;
}
.issuesStatDismissed {
border-left: 3px solid #888;
}
.issuesStatTotal {
border-left: 3px solid rgba(var(--accent-light-rgb), 0.6);
}
.issuesList {
display: flex;
flex-direction: column;
gap: 8px;
}
.issuesLoading,
.issuesEmpty {
text-align: center;
padding: 60px 20px;
color: rgba(255, 255, 255, 0.4);
}
.issuesLoading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
font-size: 14px;
}
.issuesSpinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: rgba(var(--accent-light-rgb), 0.7);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.issuesEmptyIcon {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.5;
}
.issuesEmptyTitle {
font-size: 16px;
font-weight: 500;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 6px;
}
.issuesEmptyText {
font-size: 13px;
color: rgba(255, 255, 255, 0.35);
}
.issueCard {
display: flex;
align-items: stretch;
gap: 14px;
width: 100%;
padding: 14px 16px;
text-align: left;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
color: inherit;
cursor: pointer;
transition:
transform 0.2s ease,
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease;
}
.issueCard:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
}
.issueCardLeft {
flex-shrink: 0;
display: flex;
align-items: center;
}
.issueCardThumb {
width: 52px;
height: 52px;
border-radius: 8px;
object-fit: cover;
}
.issueCardThumbPlaceholder {
width: 52px;
height: 52px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
}
.issueCardCenter {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.issueCardTitleRow {
display: flex;
align-items: center;
gap: 6px;
}
.issueCardCategoryIcon {
font-size: 14px;
flex-shrink: 0;
}
.issueCardTitle {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardResponded {
font-size: 13px;
flex-shrink: 0;
opacity: 0.7;
}
.issueCardEntity {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
flex-wrap: wrap;
}
.issueCardEntityType {
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.issueCardEntityName {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardMetaLine {
color: rgba(255, 255, 255, 0.35);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issueCardDescription {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 500px;
}
.issueCardFooter {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
}
.issueCardProfile {
background: rgba(255, 255, 255, 0.06);
padding: 1px 6px;
border-radius: 4px;
}
.issueCardDate {
opacity: 0.8;
}
.issueCardRight {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
}
.issueStatusBadge {
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
white-space: nowrap;
}
.issueStatusOpen {
background: rgba(255, 165, 0, 0.15);
color: #ffa500;
border: 1px solid rgba(255, 165, 0, 0.25);
}
.issueStatusProgress {
background: rgba(77, 166, 255, 0.15);
color: #4da6ff;
border: 1px solid rgba(77, 166, 255, 0.25);
}
.issueStatusResolved {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.25);
}
.issueStatusDismissed {
background: rgba(136, 136, 136, 0.15);
color: #888;
border: 1px solid rgba(136, 136, 136, 0.25);
}
.issuePriorityDot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.issuePriorityLow {
background: #666;
}
.issuePriorityNormal {
background: #4da6ff;
}
.issuePriorityHigh {
background: #ff4d4d;
box-shadow: 0 0 6px rgba(255, 77, 77, 0.4);
}
.modalOverlay {
position: fixed;
inset: 0;
z-index: 11000;
background: rgba(5, 9, 16, 0.76);
backdrop-filter: blur(18px);
display: grid;
place-items: center;
padding: 18px;
}
.modal {
width: min(1060px, 100%);
max-height: min(90vh, 980px);
overflow: auto;
border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(16, 22, 34, 0.98), rgba(12, 16, 25, 0.98));
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.45);
}
.issueDetailModal {
max-width: 750px;
width: 95vw;
max-height: 85vh;
}
.modalHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 20px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modalHeaderTitle {
margin: 0 0 8px;
font-size: 1.4rem;
}
.modalClose {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 14px;
width: 40px;
height: 40px;
cursor: pointer;
}
.modalBody {
padding: 22px;
display: grid;
gap: 18px;
}
.issueHero {
display: flex;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
margin-bottom: 14px;
}
.issueHeroArtGroup {
position: relative;
flex-shrink: 0;
}
.issueHeroArtistThumb {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
position: absolute;
top: -6px;
left: -10px;
border: 2px solid rgba(30, 30, 30, 0.9);
z-index: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.issueHeroAlbumArt {
width: 140px;
height: 140px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.issueHeroAlbumPlaceholder {
width: 140px;
height: 140px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
color: rgba(255, 255, 255, 0.15);
}
.issueHeroInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.issueHeroArtist {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 500;
}
.issueHeroAlbum {
font-size: 20px;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.issueHeroTrackName {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
margin-top: 2px;
}
.issueHeroMeta {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.issueDetailInfoBar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 14px;
}
.issueDetailInfoLeft,
.issueDetailInfoRight {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.issueDetailCategory {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
.issueDetailDate {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
}
.issueDetailProfile {
background: rgba(255, 255, 255, 0.06);
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.issueDetailSection {
margin-bottom: 16px;
}
.issueDetailSectionTitle {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 8px;
font-weight: 500;
}
.issueDetailTitleText {
font-size: 15px;
font-weight: 500;
color: #fff;
margin-bottom: 8px;
}
.issueDetailDescription {
font-size: 13px;
color: rgba(255, 255, 255, 0.65);
line-height: 1.6;
white-space: pre-wrap;
}
.issueDetailNoDesc {
font-size: 13px;
color: rgba(255, 255, 255, 0.25);
font-style: italic;
}
.issueDetailMetaGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.issueMetaItem {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.issueMetaIcon {
font-size: 14px;
opacity: 0.5;
flex-shrink: 0;
width: 18px;
text-align: center;
}
.issueMetaLabel {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: rgba(255, 255, 255, 0.35);
flex-shrink: 0;
}
.issueMetaValue {
font-size: 13px;
color: rgba(255, 255, 255, 0.85);
margin-left: auto;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.issueActionButtons {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.issueActionButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
font-family: inherit;
}
.issueActionButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.issueActionDownload {
background: rgba(var(--accent-light-rgb), 0.12);
color: rgb(var(--accent-light-rgb));
border-color: rgba(var(--accent-light-rgb), 0.25);
}
.issueActionDownload:hover:not(:disabled) {
background: rgba(var(--accent-light-rgb), 0.22);
border-color: rgba(var(--accent-light-rgb), 0.4);
}
.issueActionWishlist {
background: rgba(255, 165, 0, 0.1);
color: #ffa500;
border-color: rgba(255, 165, 0, 0.2);
}
.issueActionWishlist:hover:not(:disabled) {
background: rgba(255, 165, 0, 0.2);
border-color: rgba(255, 165, 0, 0.35);
}
@media (max-width: 900px) {
.issuesStats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.issueHero {
flex-direction: column;
align-items: center;
text-align: center;
}
.issueHeroInfo {
align-items: center;
}
.issueDetailInfoBar {
flex-direction: column;
align-items: flex-start;
}
}
@media (max-width: 720px) {
.issuesContainer {
padding: 18px;
}
.issuesHeaderRight {
width: 100%;
}
.issuesFilters {
width: 100%;
}
.issuesStats {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.issuesList {
gap: 10px;
}
.issueCard {
flex-direction: column;
}
.issueCardLeft {
width: 100%;
}
.issueCardThumb,
.issueCardThumbPlaceholder {
width: 100%;
height: 180px;
}
.issueCardRight {
align-items: flex-start;
flex-direction: row;
justify-content: space-between;
}
}

View file

@ -0,0 +1,422 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { Select } from '@/components/form';
import { Show } from '@/components/primitives';
import { useProfile, useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssuePriority, IssueRecord, IssuesSearch } from '../-issues.types';
import {
issueCountsQueryOptions,
issueListQueryOptions,
invalidateIssuesQueries,
} from '../-issues.api';
import {
formatIssueDate,
getEntityDetails,
getEntityLabel,
getEntityName,
getIssueArtwork,
getPriorityClassName,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
getIssueCategoryMeta,
getIssueStatusMeta,
parseSnapshot,
} from '../-issues.helpers';
import { ISSUE_CATEGORY_VALUES, ISSUE_SEARCH_STATUS_VALUES } from '../-issues.types';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
export function IssuesPage() {
useReactPageShell('issues');
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useSearch();
const clearIssueSelection = () => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, issueId: undefined }),
replace: true,
});
};
const handleMutationSuccess = () => {
clearIssueSelection();
void invalidateIssuesQueries(queryClient);
};
return (
<>
<IssueBoard />
<IssueDetailModal
issueId={params.issueId}
onClose={clearIssueSelection}
onMutationSuccess={handleMutationSuccess}
/>
</>
);
}
function IssueBoard() {
const { isAdmin, profileId } = useProfile();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useSearch();
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
});
const issuesQuery = useQuery({
...issueListQueryOptions(profileId, params),
});
const openIssue = (issueId: number) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, issueId }),
});
};
const onCategoryChange = (category: IssuesSearch['category']) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, category }),
replace: true,
});
};
const onStatusChange = (status: IssuesSearch['status']) => {
void navigate({
to: Route.fullPath,
search: (prev) => ({ ...prev, status }),
replace: true,
});
};
return (
<div className={styles.issuesContainer} data-testid="issues-board">
<IssueBoardHeader
isAdmin={isAdmin}
category={params.category}
status={params.status}
onCategoryChange={onCategoryChange}
onStatusChange={onStatusChange}
/>
<IssueBoardStats counts={countsQuery.data ?? EMPTY_ISSUE_COUNTS} />
<IssueBoardList
categoryFilter={params.category}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
showReporterName={isAdmin}
onIssueSelect={openIssue}
statusFilter={params.status}
/>
</div>
);
}
function IssueBoardHeader({
category,
isAdmin,
status,
onCategoryChange,
onStatusChange,
}: {
category: IssuesSearch['category'];
isAdmin: boolean;
status: IssuesSearch['status'];
onCategoryChange: (category: IssuesSearch['category']) => void;
onStatusChange: (status: IssuesSearch['status']) => void;
}) {
return (
<div className={styles.issuesHeader} id="issues-header">
<div className={styles.issuesHeaderLeft}>
<h2 className={styles.issuesTitle}>Issues</h2>
<p className={styles.issuesSubtitle} id="issues-subtitle">
{isAdmin
? 'Manage and resolve reported library problems'
: 'Track and resolve library problems'}
</p>
</div>
<div className={styles.issuesHeaderRight}>
<div className={styles.issuesFilters} id="issues-filters">
<Select
id="issues-filter-status"
aria-label="Status"
value={status}
onChange={(event) => onStatusChange(event.target.value as IssuesSearch['status'])}
>
{ISSUE_SEARCH_STATUS_VALUES.map((option) => (
<option key={option} value={option}>
{getIssueStatusFilterLabel(option)}
</option>
))}
</Select>
<Select
id="issues-filter-category"
aria-label="Category"
value={category}
onChange={(event) => onCategoryChange(event.target.value as IssuesSearch['category'])}
>
<option value="all">All Categories</option>
{ISSUE_CATEGORY_FILTER_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{getIssueCategoryFilterOptions(group).map((option) => (
<option key={option} value={option}>
{ISSUE_CATEGORY_META[option].label}
</option>
))}
</optgroup>
))}
</Select>
</div>
</div>
</div>
);
}
function IssueBoardStats({ counts }: { counts: IssueCounts }) {
return (
<div className={styles.issuesStats} id="issues-stats" data-testid="issue-counts">
<IssueStatCard className={styles.issuesStatOpen} label="Open" value={counts.open} />
<IssueStatCard
className={styles.issuesStatProgress}
label="In Progress"
value={counts.in_progress}
/>
<IssueStatCard
className={styles.issuesStatResolved}
label="Resolved"
value={counts.resolved}
/>
<IssueStatCard
className={styles.issuesStatDismissed}
label="Dismissed"
value={counts.dismissed}
/>
<IssueStatCard className={styles.issuesStatTotal} label="Total" value={counts.total} />
</div>
);
function IssueStatCard({
className,
label,
value,
}: {
className: string;
label: string;
value: number;
}) {
return (
<div className={`${styles.issuesStatCard} ${className}`}>
<div className={styles.issuesStatNumber}>{value}</div>
<div className={styles.issuesStatLabel}>{label}</div>
</div>
);
}
}
function IssueBoardList({
categoryFilter,
issues,
issuesError,
issuesLoading,
onIssueSelect,
showReporterName,
statusFilter,
}: {
categoryFilter: string;
issues: IssueRecord[];
issuesError: unknown;
issuesLoading: boolean;
onIssueSelect: (issueId: number) => void;
showReporterName: boolean;
statusFilter: IssuesSearch['status'];
}) {
return (
<div className={styles.issuesList} id="issues-list" data-testid="issue-list">
<IssueBoardListContent />
</div>
);
function IssueBoardListContent() {
if (issuesLoading) {
return (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
);
}
if (issuesError) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issues</div>
<div className={styles.issuesEmptyText}>
{issuesError instanceof Error ? issuesError.message : 'Unknown error'}
</div>
</div>
);
}
if (issues.length === 0) {
return (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyIcon} aria-hidden="true">
🔍
</div>
<div className={styles.issuesEmptyTitle}>No issues found</div>
<div className={styles.issuesEmptyText}>
{statusFilter !== 'open' || categoryFilter !== 'all'
? 'Try adjusting your filters'
: 'No issues have been reported yet'}
</div>
</div>
);
}
return issues.map((issue) => (
<IssueBoardCard
key={issue.id}
issue={issue}
showReporterName={showReporterName}
onIssueSelect={onIssueSelect}
/>
));
}
}
function IssueBoardCard({
issue,
showReporterName,
onIssueSelect,
}: {
issue: IssueRecord;
showReporterName: boolean;
onIssueSelect: (issueId: number) => void;
}) {
const snapshot = parseSnapshot(issue.snapshot_data);
const artwork = getIssueArtwork(snapshot);
const entityName = getEntityName(issue, snapshot);
const details = getEntityDetails(issue, snapshot);
const statusMeta = getIssueStatusMeta(issue.status) || ISSUE_STATUS_META.open;
const catMeta = getIssueCategoryMeta(issue.category) || ISSUE_CATEGORY_META.other;
const priorityClass = getIssuePriorityClassName(getPriorityClassName(issue.priority));
const statusClassName = getIssueStatusClassName(issue.status);
const createdDate = formatIssueDate(issue.created_at);
return (
<button
className={styles.issueCard}
type="button"
data-testid={`issue-card-${issue.id}`}
onClick={() => onIssueSelect(issue.id)}
>
<div className={styles.issueCardLeft}>
{artwork ? (
<img className={styles.issueCardThumb} src={artwork} alt="" />
) : (
<div className={styles.issueCardThumbPlaceholder}>{catMeta.icon}</div>
)}
</div>
<div className={styles.issueCardCenter}>
<div className={styles.issueCardTitleRow}>
<span className={styles.issueCardCategoryIcon} title={catMeta.label}>
{catMeta.icon}
</span>
<span className={styles.issueCardTitle}>{issue.title}</span>
<Show when={issue.admin_response}>
<span className={styles.issueCardResponded} title="Admin has responded">
💬
</span>
</Show>
</div>
<div className={styles.issueCardEntity}>
<span className={styles.issueCardEntityType}>{getEntityLabel(issue.entity_type)}</span>
<span className={styles.issueCardEntityName}>{entityName}</span>
<Show when={details.length}>
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
</Show>
</div>
<Show when={issue.description}>
<div className={styles.issueCardDescription}>{issue.description}</div>
</Show>
<div className={styles.issueCardFooter}>
<span className={styles.issueCardDate}>{createdDate}</span>
<Show when={showReporterName && issue.reporter_name}>
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
</Show>
</div>
</div>
<div className={styles.issueCardRight}>
<span className={`${styles.issueStatusBadge} ${statusClassName}`}>{statusMeta.label}</span>
<span
className={`${styles.issuePriorityDot} ${priorityClass}`}
title={`${issue.priority} priority`}
/>
</div>
</button>
);
}
const EMPTY_ISSUE_COUNTS: IssueCounts = {
open: 0,
in_progress: 0,
resolved: 0,
dismissed: 0,
total: 0,
};
const ISSUE_STATUS_CLASS_NAMES: Record<IssueRecord['status'], string> = {
open: styles.issueStatusOpen,
in_progress: styles.issueStatusProgress,
resolved: styles.issueStatusResolved,
dismissed: styles.issueStatusDismissed,
};
const ISSUE_PRIORITY_CLASS_NAMES: Record<IssuePriority, string> = {
high: styles.issuePriorityHigh,
low: styles.issuePriorityLow,
normal: styles.issuePriorityNormal,
};
function getIssueStatusFilterLabel(status: IssuesSearch['status']): string {
if (status === 'all') return 'All Statuses';
return getIssueStatusMeta(status)?.label || status.replace(/_/g, ' ');
}
function getIssueStatusClassName(status: IssueRecord['status']): string {
return ISSUE_STATUS_CLASS_NAMES[status] || styles.issueStatusOpen;
}
function getIssuePriorityClassName(priority: IssuePriority): string {
return ISSUE_PRIORITY_CLASS_NAMES[priority] || styles.issuePriorityNormal;
}
const ISSUE_CATEGORY_FILTER_GROUPS = [
{
label: 'Track Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('track'),
},
{
label: 'Album Issues',
matches: (applies: Array<'track' | 'album' | 'artist'>) =>
applies.length === 1 && applies.includes('album'),
},
{
label: 'Both',
matches: (applies: Array<'track' | 'album' | 'artist'>) => applies.length > 1,
},
] as const;
function getIssueCategoryFilterOptions(group: (typeof ISSUE_CATEGORY_FILTER_GROUPS)[number]) {
return ISSUE_CATEGORY_VALUES.filter((category) =>
group.matches(ISSUE_CATEGORY_META[category].applies),
);
}

View file

@ -0,0 +1,41 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath } from '@/platform/shell/bridge';
import {
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
} from './-issues.api';
import { issueSearchSchema } from './-issues.types';
import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({
validateSearch: issueSearchSchema,
beforeLoad: ({ context }) => {
const { bridge } = context.shell;
if (!bridge.isPageAllowed('issues')) {
throw redirect({ href: getProfileHomePath(bridge), replace: true });
}
},
loaderDeps: ({ search }) => ({
status: search.status,
category: search.category,
issueId: search.issueId ?? null,
}),
loader: async ({ context, deps }) => {
const { profile } = context.shell;
await Promise.all([
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
deps.issueId
? context.queryClient.ensureQueryData(
issueDetailQueryOptions(profile.profileId, deps.issueId),
)
: Promise.resolve(),
]);
},
component: IssuesPage,
});

6
webui/src/test/msw.ts Normal file
View file

@ -0,0 +1,6 @@
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
export { HttpResponse, http };
export const server = setupServer();

1
webui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,5 +1,7 @@
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
const PAGE_WILL_CHANGE_EVENT = 'ss:webui-page-will-change';
// Global state management
let currentPage = 'dashboard';
let currentTrack = null;

View file

@ -4537,17 +4537,6 @@ const TROUBLESHOOT_RULES = [
'Consider switching to iTunes temporarily to continue working'
]
},
{
selector: '.issue-card.status-open, .issues-stat-open',
title: 'Open Issues in Library',
steps: [
'Open issues have been reported for tracks in your library',
'Go to the Issues page to review and resolve them',
'Common issues: wrong track downloaded, bad metadata, low audio quality',
'Each issue has fix suggestions and action buttons'
],
action: { label: 'View Issues', fn: () => navigateToPage('issues') }
},
];
function activateTroubleshootMode() {

View file

@ -1,5 +1,20 @@
// INITIALIZATION
// ===============================
let navigationEpoch = 0;
function notifyPageWillChange(nextPageId) {
const fromPageId = typeof currentPage === 'string' ? currentPage : null;
if (fromPageId === nextPageId) return;
window.dispatchEvent(
new CustomEvent(PAGE_WILL_CHANGE_EVENT, {
detail: {
fromPageId,
toPageId: nextPageId,
},
}),
);
}
// ---- Accent Color System ----
@ -186,55 +201,54 @@ function applyReduceEffects(enabled) {
// ── Profile System ─────────────────────────────────────────────
let currentProfile = null;
const PROFILE_CONTEXT_CHANGED_EVENT = 'ss:webui-profile-context-changed';
// Normalize legacy allowed_pages entries so the profile edit forms (which are
// built from the new pageLabels map containing 'search') don't drop access
// when saving a profile that was stored before the Search page rename.
// 'downloads' was the old Search id, 'artists' was the retired inline page.
function _normalizeLegacyAllowedPages(pages) {
if (!Array.isArray(pages)) return pages;
const mapped = pages.map(id => (id === 'downloads' || id === 'artists') ? 'search' : id);
return [...new Set(mapped)];
function notifyProfileContextChanged() {
window.dispatchEvent(new CustomEvent(PROFILE_CONTEXT_CHANGED_EVENT));
}
function _normalizeLegacyHomePage(homePage) {
if (homePage === 'downloads' || homePage === 'artists') return 'search';
return homePage;
function setCurrentProfile(profile) {
currentProfile = profile;
updateProfileIndicator();
notifyProfileContextChanged();
}
// Temporary compatibility shim until existing profile rows are migrated to
// the current page ids.
const LEGACY_PROFILE_PAGE_ALIASES = {
downloads: 'search',
artists: 'search',
};
function normalizeProfilePageId(pageId) {
return LEGACY_PROFILE_PAGE_ALIASES[pageId] || pageId;
}
function normalizeProfilePageList(pageIds) {
if (!Array.isArray(pageIds)) return pageIds;
return pageIds.map(normalizeProfilePageId);
}
function getProfileHomePage() {
if (!currentProfile) return 'dashboard';
// Legacy profiles stored the Search page as either 'downloads' (the old id
// before the rename) or 'artists' (the retired inline Artists page).
// Both fold into the unified Search page now.
let home = currentProfile.home_page;
if (home === 'downloads' || home === 'artists') home = 'search';
if (home) return home;
if (currentProfile.home_page) return normalizeProfilePageId(currentProfile.home_page);
return currentProfile.is_admin ? 'dashboard' : 'discover';
}
function isPageAllowed(pageId) {
if (!currentProfile) return true;
if (currentProfile.id === 1) return true;
if (pageId === 'help' || pageId === 'issues') return true;
if (pageId === 'settings') return currentProfile.is_admin;
if (pageId === 'artist-detail') {
// artist-detail is reachable from both Library and Search results, so
// either grant unlocks it. Without this, a Search-only profile couldn't
// open a source artist, and a legacy artists-only profile would hit the
// home-redirect recursion path below.
const ap = currentProfile.allowed_pages;
const normalizedPageId = normalizeProfilePageId(pageId);
if (normalizedPageId === 'help' || normalizedPageId === 'issues') return true;
if (normalizedPageId === 'settings') return currentProfile.is_admin;
if (normalizedPageId === 'artist-detail') {
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true;
return ap.includes('library') || ap.includes('search')
|| ap.includes('downloads') || ap.includes('artists');
return ap.includes('library') || ap.includes('search');
}
const ap = currentProfile.allowed_pages;
const ap = normalizeProfilePageList(currentProfile.allowed_pages);
if (!ap) return true; // null = all pages
if (ap.includes(pageId)) return true;
// Legacy compat: 'downloads' (old Search id) and 'artists' (retired inline
// Artists page) both fold into the unified 'search' page.
if (pageId === 'search' && (ap.includes('downloads') || ap.includes('artists'))) return true;
if ((pageId === 'downloads' || pageId === 'artists') && ap.includes('search')) return true;
if (ap.includes(normalizedPageId)) return true;
return false;
}
@ -244,6 +258,26 @@ function canDownload() {
return currentProfile.can_download !== false && currentProfile.can_download !== 0;
}
function getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
}
function activatePage(pageId, options = {}) {
const forceReload = options.forceReload === true;
const pageElement = document.getElementById(`${pageId}-page`);
const isPageVisible = pageElement ? pageElement.classList.contains('active') : false;
if (!forceReload && pageId === currentPage && isPageVisible) return;
showLegacyPage(pageId);
setActivePageChrome(pageId);
loadPageData(pageId);
}
function renderProfileAvatar(el, profile) {
// Renders avatar as image (if avatar_url set) or colored initial fallback
// Preserves existing classes, ensures 'profile-avatar' is present
@ -273,8 +307,7 @@ async function initProfileSystem() {
const currentRes = await fetch('/api/profiles/current');
const currentData = await currentRes.json();
if (currentData.success && currentData.profile) {
currentProfile = currentData.profile;
updateProfileIndicator();
setCurrentProfile(currentData.profile);
// Check if launch PIN is required
if (currentData.launch_pin_required) {
@ -705,10 +738,9 @@ function showPinDialog(profile) {
window.location.reload();
return;
}
currentProfile = data.profile;
dialog.style.display = 'none';
hideProfilePicker();
updateProfileIndicator();
setCurrentProfile(data.profile);
initApp();
return;
} else {
@ -755,8 +787,7 @@ async function selectProfile(profileId) {
});
const data = await res.json();
if (data.success) {
currentProfile = data.profile;
updateProfileIndicator();
setCurrentProfile(data.profile);
// Join profile-scoped WebSocket room for watchlist/wishlist count updates
if (socket && socket.connected) {
socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId });
@ -1381,6 +1412,96 @@ function _invalidateListenBrainzCache() {
}
}
const PROFILE_PAGE_LABELS = {
dashboard: 'Dashboard',
sync: 'Sync',
search: 'Search',
discover: 'Discover',
watchlist: 'Watchlist',
wishlist: 'Wishlist',
automations: 'Automations',
'active-downloads': 'Downloads',
library: 'Library',
stats: 'Listening Stats',
'playlist-explorer': 'Playlist Explorer',
import: 'Import',
tools: 'Tools',
hydrabase: 'Hydrabase',
issues: 'Issues',
help: 'Help & Docs',
settings: 'Settings',
'artist-detail': 'Artist Detail',
};
function getProfilePageLabel(pageId) {
return PROFILE_PAGE_LABELS[pageId] || pageId.split('-').map(part => part ? part[0].toUpperCase() + part.slice(1) : part).join(' ');
}
function getProfilePageSelectOptions(profileSettings = {}) {
const options = [];
const seen = new Set();
const homeSelect = document.getElementById('new-profile-home-page');
const normalizedHomePage = normalizeProfilePageId(profileSettings.home_page);
if (homeSelect) {
homeSelect.querySelectorAll('option').forEach(option => {
if (!option.value || seen.has(option.value)) return;
options.push({
value: option.value,
label: option.textContent?.trim() || getProfilePageLabel(option.value),
});
seen.add(option.value);
});
}
if (normalizedHomePage && !seen.has(normalizedHomePage)) {
options.push({
value: normalizedHomePage,
label: getProfilePageLabel(normalizedHomePage),
});
seen.add(normalizedHomePage);
}
return options;
}
function getProfilePageAccessOptions(profileSettings = {}) {
const options = [];
const seen = new Set();
const allowedSet = Array.isArray(profileSettings.allowed_pages)
? new Set(normalizeProfilePageList(profileSettings.allowed_pages))
: null;
const accessContainer = document.getElementById('new-profile-allowed-pages');
if (accessContainer) {
accessContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (seen.has(cb.value)) return;
options.push({
value: cb.value,
label: cb.parentElement?.textContent?.trim() || getProfilePageLabel(cb.value),
checked: cb.disabled ? true : (allowedSet ? allowedSet.has(cb.value) : true),
disabled: cb.disabled,
});
seen.add(cb.value);
});
}
if (allowedSet) {
allowedSet.forEach(pageId => {
if (seen.has(pageId)) return;
options.push({
value: pageId,
label: getProfilePageLabel(pageId),
checked: true,
disabled: false,
});
seen.add(pageId);
});
}
return options;
}
function initProfileManagement() {
const manageBtn = document.getElementById('manage-profiles-btn');
const closeBtn = document.getElementById('profile-manage-close');
@ -1597,13 +1718,8 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
const isAdmin = currentProfile && currentProfile.is_admin;
const isEditingAdmin = profileSettings.is_admin;
const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
// 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are
// normalized on read below so saving a legacy profile upgrades it.
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover',
automations: 'Automations', library: 'Library', stats: 'Listening Stats',
'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs'
};
const pageSelectOptions = getProfilePageSelectOptions(profileSettings);
const pageAccessOptions = getProfilePageAccessOptions(profileSettings);
const form = document.createElement('div');
form.id = 'profile-edit-form';
@ -1653,16 +1769,12 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
defaultOpt.value = '';
defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
// Normalize legacy ids so the form shows the right options/selection for
// profiles saved before the Search page rename.
const allowedSet = _normalizeLegacyAllowedPages(profileSettings.allowed_pages);
const normalizedHome = _normalizeLegacyHomePage(profileSettings.home_page);
Object.entries(pageLabels).forEach(([id, label]) => {
if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted
const normalizedHome = profileSettings.home_page;
pageSelectOptions.forEach(({ value, label }) => {
const opt = document.createElement('option');
opt.value = id;
opt.value = value;
opt.textContent = label;
if (id === normalizedHome) opt.selected = true;
if (value === normalizedHome) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
@ -1678,26 +1790,18 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
const apContainer = document.createElement('div');
apContainer.className = 'profile-page-checkboxes';
Object.entries(pageLabels).forEach(([id, label]) => {
pageAccessOptions.forEach(({ value, label, checked, disabled }) => {
const lbl = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = id;
cb.checked = !allowedSet || allowedSet.includes(id);
cb.value = value;
cb.checked = checked;
cb.disabled = disabled;
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(' ' + label));
apContainer.appendChild(lbl);
pageCheckboxes.push(cb);
});
// Always-on help
const helpLbl = document.createElement('label');
const helpCb = document.createElement('input');
helpCb.type = 'checkbox';
helpCb.checked = true;
helpCb.disabled = true;
helpLbl.appendChild(helpCb);
helpLbl.appendChild(document.createTextNode(' Help & Docs'));
apContainer.appendChild(helpLbl);
form.appendChild(apContainer);
const dlLabel = document.createElement('label');
@ -1727,8 +1831,9 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
// Admin-only fields
if (isAdmin && !isEditingAdmin && pageCheckboxes.length) {
const allChecked = pageCheckboxes.every(cb => cb.checked);
payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
const editablePageCheckboxes = pageCheckboxes.filter(cb => !cb.disabled);
const allChecked = editablePageCheckboxes.every(cb => cb.checked);
payload.allowed_pages = allChecked ? null : editablePageCheckboxes.filter(cb => cb.checked).map(cb => cb.value);
payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true;
}
@ -1749,6 +1854,7 @@ function showProfileEditForm(profileId, currentName, currentColor, currentAvatar
if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages;
if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download;
updateProfileIndicator();
notifyProfileContextChanged();
}
loadProfileManageList();
} else {
@ -1787,8 +1893,6 @@ function showSelfEditForm() {
const existing = document.getElementById('self-edit-form');
if (existing) existing.remove();
// 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are
// normalized on read below so saving a legacy profile upgrades it.
const pageLabels = {
dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover',
automations: 'Automations', library: 'Library', stats: 'Listening Stats',
@ -1826,16 +1930,12 @@ function showSelfEditForm() {
defaultOpt.value = '';
defaultOpt.textContent = 'Default (Discover)';
homeSelect.appendChild(defaultOpt);
// Normalize legacy ids so the form shows the right options/selection for
// profiles saved before the Search page rename.
const ap = _normalizeLegacyAllowedPages(currentProfile.allowed_pages);
const normalizedHome = _normalizeLegacyHomePage(currentProfile.home_page);
Object.entries(pageLabels).forEach(([id, label]) => {
if (ap && !ap.includes(id)) return;
const normalizedHome = currentProfile.home_page;
getProfilePageSelectOptions({ home_page: normalizedHome }).forEach(({ value, label }) => {
const opt = document.createElement('option');
opt.value = id;
opt.value = value;
opt.textContent = label;
if (id === normalizedHome) opt.selected = true;
if (value === normalizedHome) opt.selected = true;
homeSelect.appendChild(opt);
});
form.appendChild(homeSelect);
@ -2003,9 +2103,6 @@ function initApp() {
// Start always-on download polling (batched, minimal overhead)
startGlobalDownloadPolling();
// Load issues badge count
loadIssuesBadge();
// Load initial data
loadInitialData();
@ -2037,22 +2134,20 @@ function initializeNavigation() {
});
});
window.addEventListener('popstate', (event) => {
const page = (event.state && event.state.page) || _getPageFromPath();
if (page && page !== currentPage) {
navigateToPage(page, { skipPushState: true });
}
});
}
const _DEEPLINK_VALID_PAGES = new Set([
'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations',
'dashboard', 'sync', 'search', 'discover', 'automations',
'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist',
'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer',
'hydrabase', 'tools'
]);
function _getPageFromPath() {
const router = getWebRouter();
const resolved = router?.resolvePageId?.(window.location.pathname);
if (resolved) return resolved;
const path = window.location.pathname.replace(/^\/+|\/+$/g, '');
if (!path) return 'dashboard';
const basePage = path.split('/')[0];
@ -2163,96 +2258,55 @@ function initializeWatchlist() {
}
function navigateToPage(pageId, options = {}) {
// Backwards-compat aliases — both legacy ids fold into the unified Search page.
// 'downloads' was the Search page's old id; 'artists' was the retired inline
// Artists page, now replaced by clicking artists from the unified Search.
if (pageId === 'downloads' || pageId === 'artists') pageId = 'search';
navigationEpoch += 1;
if (pageId === currentPage) return;
if (!options.forceReload && pageId === currentPage) return;
// Permission guard — redirect to home page if not allowed
if (!isPageAllowed(pageId)) {
const home = getProfileHomePage();
if (home !== currentPage && isPageAllowed(home)) {
navigateToPage(home);
navigateToPage(home, options);
}
return;
}
// Update navigation buttons (only if there's a nav button for this page).
// Pages reachable from many surfaces (artist-detail, playlist-explorer)
// intentionally have no [data-page] match here — the sidebar shouldn't
// imply a section the user didn't actually navigate via.
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
const navButton = document.querySelector(`[data-page="${pageId}"]`);
if (navButton) {
navButton.classList.add('active');
} else if (pageId === 'artist-detail') {
// Artist-detail is a "pseudo-page" reachable from Library, Search,
// or the global search popover — it has no [data-page] match. Treat
// it as a Library context so the sidebar still anchors the user
// somewhere instead of showing a blank active state.
const libraryBtn = document.querySelector('[data-page="library"]');
if (libraryBtn) libraryBtn.classList.add('active');
const router = getWebRouter();
if (router && !options.skipRouteChange) {
notifyPageWillChange(pageId);
const route = router.routeManifest?.find((entry) => entry.pageId === pageId);
if (route?.kind === 'react') {
showReactHost(pageId);
setActivePageChrome(pageId);
}
return router.navigateToPage(pageId, { replace: options.replace === true });
}
// Update pages
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
document.getElementById(`${pageId}-page`).classList.add('active');
currentPage = pageId;
// Refresh the Library button label so artist-detail shows a breadcrumb
// ("Library / Artist Name") and other pages show plain "Library".
if (typeof _updateSidebarLibraryBreadcrumb === 'function') {
_updateSidebarLibraryBreadcrumb();
// Fallback path for initial bootstrap or environments without TanStack routing.
const route = router?.routeManifest?.find((entry) => entry.pageId === pageId);
notifyPageWillChange(pageId);
const legacyPageElement = document.getElementById(`${pageId}-page`);
if (route?.kind === 'react' || !legacyPageElement) {
showReactHost(pageId);
setActivePageChrome(pageId);
} else {
activatePage(pageId, { forceReload: options.forceReload === true });
}
if (!options.skipPushState) {
const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId;
if (window.location.pathname !== urlPath) {
history.pushState({ page: pageId }, '', urlPath);
}
}
// Show/hide global search bar (hide on search page where the unified search lives)
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
// Show/hide discover download sidebar based on page
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
// Show sidebar on discover page if there are active downloads
const activeDownloads = Object.keys(discoverDownloads || {}).length;
console.log(`📊 [NAVIGATE] Discover page - ${activeDownloads} active downloads`);
if (activeDownloads > 0) {
// Update the sidebar UI to render the bubbles
console.log(`🔄 [NAVIGATE] Updating discover download bar UI`);
updateDiscoverDownloadBar();
if (options.replace === true) {
history.replaceState({ page: pageId }, '', urlPath);
} else {
history.pushState({ page: pageId }, '', urlPath);
}
} else {
// Always hide sidebar on other pages
downloadSidebar.classList.add('hidden');
}
}
// Load page-specific data
loadPageData(pageId);
// Update page background particles
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
// Update worker orbs
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
return true;
}
// REPLACE your old loadPageData function with this one:
// REPLACE your old loadPageData function with this corrected one
async function loadPageData(pageId) {
try {
// Stop any active polling when navigating away
@ -2281,7 +2335,6 @@ async function loadPageData(pageId) {
initializeSearchModeToggle();
initializeFilters();
break;
// 'artists' page retired — aliased to 'search' at the top of navigateToPage
case 'active-downloads':
loadActiveDownloadsPage();
break;
@ -2364,9 +2417,6 @@ async function loadPageData(pageId) {
case 'automations':
await loadAutomations();
break;
case 'issues':
await loadIssuesPage();
break;
case 'help':
initializeDocsPage();
break;
@ -2376,14 +2426,3 @@ async function loadPageData(pageId) {
showToast(`Failed to load ${pageId} data`, 'error');
}
}
// ===============================
// SERVICE STATUS MONITORING
// ===============================
// Legacy function - now handled by fetchAndUpdateServiceStatus
// Keeping this for compatibility but it's no longer actively used
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
// ===============================

View file

@ -700,6 +700,27 @@ let artistDetailPageState = {
enhancedTrackSort: {}
};
function clearArtistDetailPageState() {
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
artistDetailPageState.completionController = null;
}
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.currentArtistSource = null;
artistDetailPageState.originStack = [];
}
if (typeof window !== 'undefined') {
window.addEventListener(PAGE_WILL_CHANGE_EVENT, (event) => {
const detail = event.detail || {};
if (detail.fromPageId === 'artist-detail' && detail.toPageId !== 'artist-detail') {
clearArtistDetailPageState();
}
});
}
// Discography filter state
let discographyFilterState = {
categories: { albums: true, eps: true, singles: true },

View file

@ -3264,42 +3264,6 @@
}
}
/* ======================================
ISSUES PAGE Mobile (supplement)
====================================== */
@media (max-width: 768px) {
.issues-header-right {
width: 100%;
flex-wrap: wrap;
}
.issues-filter-select {
flex: 1;
min-width: 0;
}
.issues-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.issues-stat-card {
min-width: 0;
padding: 10px;
}
.issues-stat-value {
font-size: 18px;
}
.issue-card-right {
flex-wrap: wrap;
gap: 6px;
}
}
/* ======================================
HELP / DOCS PAGE Mobile
====================================== */
@ -3737,4 +3701,4 @@
font-size: 12px;
padding: 9px 12px;
}
}
}

View file

@ -1161,6 +1161,9 @@ async function startDownload(index) {
async function loadInitialData() {
try {
const initialPath = window.location.pathname;
const initialNavigationEpoch = navigationEpoch;
// Load artist bubble state first
await hydrateArtistBubblesFromSnapshot();
@ -1177,14 +1180,24 @@ async function loadInitialData() {
? urlPage
: homePage;
history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash);
if (targetPage !== 'dashboard') {
navigateToPage(targetPage, { skipPushState: true });
} else {
await loadDashboardData();
loadDashboardSyncHistory();
if (window.location.pathname !== initialPath || navigationEpoch !== initialNavigationEpoch) {
return;
}
// Always apply the target page to the legacy shell chrome.
const router = getWebRouter();
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
setActivePageChrome(targetPage);
if (window.location.pathname !== route.path) {
history.replaceState({ page: targetPage }, '', route.path);
}
return;
}
navigateToPage(targetPage, { skipRouteChange: true, forceReload: true });
} catch (error) {
console.error('Error loading initial data:', error);
}
@ -1218,4 +1231,3 @@ async function loadDashboardData() {
}
// ===========================================

View file

@ -0,0 +1,174 @@
// SoulSync shell bridge glue
// Keep this file loaded after init.js so the legacy shell runtime state is ready.
function getWebRouter() {
return window.SoulSyncWebRouter ?? null;
}
function showLegacyPage(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const page = document.getElementById(`${pageId}-page`);
if (page) {
page.classList.add('active');
}
const reactHost = document.getElementById('webui-react-root');
if (reactHost) {
reactHost.classList.remove('active');
}
}
function setActivePageChrome(pageId) {
document.querySelectorAll('.nav-button').forEach(btn => {
btn.classList.remove('active');
});
const navButton = document.querySelector(`[data-page="${pageId}"]`);
if (navButton) {
navButton.classList.add('active');
} else if (pageId === 'artist-detail') {
// Artist detail is a Library context, so keep the sidebar anchored there.
const libraryBtn = document.querySelector('[data-page="library"]');
if (libraryBtn) {
libraryBtn.classList.add('active');
}
}
currentPage = pageId;
if (typeof _updateSidebarLibraryBreadcrumb === 'function') _updateSidebarLibraryBreadcrumb();
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
if (pageId === 'discover') {
const activeDownloads = typeof discoverDownloads !== 'undefined'
? Object.keys(discoverDownloads).length
: 0;
if (activeDownloads > 0 && typeof updateDiscoverDownloadBar === 'function') {
updateDiscoverDownloadBar();
}
} else {
downloadSidebar.classList.add('hidden');
}
}
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function showReactHost(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.remove('active');
});
const host = document.getElementById('webui-react-root');
if (host) {
host.classList.add('active');
}
currentPage = pageId;
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId);
if (window.workerOrbs) window.workerOrbs.setPage(pageId);
}
function activateLegacyPath(pathname) {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(pathname) || _getPageFromPath(pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
function syncActivePageFromLocation() {
const router = getWebRouter();
const targetPage = router?.resolvePageId?.(window.location.pathname) || _getPageFromPath(window.location.pathname);
if (!targetPage) return;
if (!isPageAllowed(targetPage)) {
const home = getProfileHomePage();
if (home !== targetPage) {
navigateToPage(home, { replace: true });
}
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
showReactHost(targetPage);
} else {
showLegacyPage(targetPage);
}
setActivePageChrome(targetPage);
}
const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
function openDownloadMissingAlbumWorkflow(input) {
if (typeof openDownloadMissingModalForArtistAlbum !== 'function') {
throw new Error('Download workflow host is not ready yet');
}
return openDownloadMissingModalForArtistAlbum(
input.virtualPlaylistId,
input.playlistName,
input.tracks,
input.album,
input.artist,
false,
);
}
function openAddToWishlistAlbumWorkflow(input) {
if (typeof openAddToWishlistModal !== 'function') {
throw new Error('Wishlist workflow host is not ready yet');
}
return openAddToWishlistModal(input.album, input.artist, input.tracks, input.albumType);
}
window.SoulSyncWorkflowActions = {
openDownloadMissingAlbum: openDownloadMissingAlbumWorkflow,
openAddToWishlistAlbum: openAddToWishlistAlbumWorkflow,
notify(message, type) {
if (typeof showToast === 'function') {
showToast(message, type);
}
},
};
window.SoulSyncWebShellBridge = {
getCurrentProfileContext() {
if (!currentProfile) return null;
return {
profileId: currentProfile.id,
isAdmin: !!currentProfile.is_admin,
};
},
isPageAllowed(pageId) {
return isPageAllowed(pageId);
},
getProfileHomePage() {
return getProfileHomePage();
},
resolveLegacyPath(pathname) {
return getWebRouter()?.resolvePageId?.(pathname) ?? null;
},
setActivePageChrome(pageId) {
setActivePageChrome(pageId);
},
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
showReactHost(pageId) {
showReactHost(pageId);
},
};
window.addEventListener('popstate', syncActivePageFromLocation);
window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,133 @@
import { expect, test, type Page } from '@playwright/test';
import { shellRouteManifest, type ShellPageId } from '../src/platform/shell/route-manifest';
async function selectProfile(page: Page, baseURL: string, profileId = 1) {
const response = await page.request.post(new URL('/api/profiles/select', baseURL).toString(), {
data: { profile_id: profileId },
});
expect(response.ok()).toBe(true);
}
async function waitForShellRoute(page: Page, pageId: string) {
if (pageId === 'issues') {
await expect
.poll(async () => page.evaluate(() => document.querySelector('.page.active')?.id ?? ''), {
timeout: 15000,
})
.toBe('webui-react-root');
await expect(page.getByTestId('issues-board')).toBeVisible({ timeout: 15000 });
return;
}
await expect
.poll(async () =>
page.evaluate(() => document.querySelector('.page.active')?.id ?? ''),
)
.toBe(`${pageId}-page`);
}
function getExpectedNavPage(pageId: ShellPageId): string {
if (pageId === 'artist-detail') {
return '';
}
return pageId;
}
async function expectNavHighlight(page: Page, pageId: ShellPageId) {
const navPage = getExpectedNavPage(pageId);
const activeNavPage = await page.evaluate(() => {
return document.querySelector('.nav-button.active')?.getAttribute('data-page') ?? '';
});
expect(activeNavPage).toBe(navPage);
}
async function verifyIssuesRoute(page: Page) {
const appRoot = page.locator('#webui-react-root');
await expect(appRoot).toBeVisible();
await expect(page.getByTestId('issues-board')).toContainText('Issues');
}
function expectedUrlPattern(path: string): RegExp {
if (path === '/issues') {
return /\/issues(?:\?status=open&category=all)?$/;
}
return new RegExp(`${path.replace('/', '\\/')}$`);
}
test('direct load activates all known top-level routes', async ({ page, baseURL }) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
for (const route of shellRouteManifest) {
const routePage = await page.context().newPage();
try {
await routePage.goto(new URL(route.path, baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(routePage, route.pageId);
await expect(routePage).toHaveURL(expectedUrlPattern(route.path));
await expectNavHighlight(routePage, route.pageId);
if (route.pageId === 'issues') {
await verifyIssuesRoute(routePage);
}
} finally {
await routePage.close();
}
}
});
test('browser history restores top-level routes', async ({ page, baseURL }) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
await page.goto(new URL('/discover', baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, 'discover');
await page.getByRole('button', { name: 'Issues' }).click();
await waitForShellRoute(page, 'issues');
await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/);
await page.goBack();
await waitForShellRoute(page, 'discover');
await expect(page).toHaveURL(/\/discover$/);
await page.goForward();
await waitForShellRoute(page, 'issues');
await expect(page).toHaveURL(/\/issues(?:\?status=open&category=all)?$/);
});
test('browser history leaves artist detail when going back to library', async ({
page,
baseURL,
}) => {
if (!baseURL) {
test.skip();
return;
}
await selectProfile(page, baseURL);
await page.goto(new URL('/library', baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, 'library');
await expect.poll(async () => page.locator('.library-artist-card').count()).toBeGreaterThan(0);
await page.locator('.library-artist-card').first().click();
await waitForShellRoute(page, 'artist-detail');
await expect(page).toHaveURL(/\/artist-detail$/);
await page.goBack();
await waitForShellRoute(page, 'library');
await expect(page).toHaveURL(/\/library$/);
});

30
webui/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2024",
"useDefineForClassFields": true,
"lib": ["ES2024", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
},
"strict": true,
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node", "vitest/globals", "@testing-library/jest-dom", "react", "react-dom"]
},
"include": [
"./src",
"./tests",
"./vitest.setup.ts",
"./vite.config.ts",
"./vitest.config.ts",
"./playwright.config.ts"
],
"references": []
}

31
webui/vite.config.ts Normal file
View file

@ -0,0 +1,31 @@
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [
tanstackRouter({
target: 'react',
}),
react(),
],
base: '/static/dist/',
root: import.meta.dirname,
resolve: {
alias: [
{
find: /^@\//,
replacement: `${path.resolve(import.meta.dirname, 'src')}/`,
},
],
},
build: {
outDir: path.resolve(import.meta.dirname, 'static/dist'),
emptyOutDir: true,
manifest: true,
rolldownOptions: {
input: [path.resolve(import.meta.dirname, 'src/app/main.tsx')],
},
},
});

18
webui/vitest.config.ts Normal file
View file

@ -0,0 +1,18 @@
import { mergeConfig, defineConfig } from 'vitest/config';
import viteConfig from './vite.config';
export default mergeConfig(
viteConfig,
defineConfig({
test: {
include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts', 'src/**/*.spec.tsx'],
exclude: ['tests/**'],
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
css: true,
restoreMocks: true,
},
}),
);

21
webui/vitest.setup.ts Normal file
View file

@ -0,0 +1,21 @@
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
import { server } from './src/test/msw';
beforeAll(() => {
server.listen({ onUnhandledRequest: 'error' });
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});
Object.defineProperty(window, 'scrollTo', {
value: vi.fn(),
writable: true,
});