Initial Vite app scaffolding & issues page impl

- File-based routing with tanstack router
  - Persist top-level navigation state in url, even for most legacy pages
  - Striving for an intuitive and simple folder structure where
    route-related code is colocated, but the amount of files is still
    kept to a minimum
- Replace native fetch with `ky`
  - Familiar api, but more polished
This commit is contained in:
Antti Kettunen 2026-04-03 20:25:12 +03:00
parent 63f313d0c2
commit d98dcd8606
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
36 changed files with 86948 additions and 159 deletions

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, url_for
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
@ -371,6 +371,166 @@ def _log_rejected_socketio_origin():
request.headers.get('X-Forwarded-Proto', ''),
)
# --- WebUI asset injection ---
_webui_react_manifest = None
_webui_react_manifest_mtime = None
_webui_react_manifest_path = Path(base_dir) / 'webui' / 'static' / 'dist' / '.vite' / 'manifest.json'
_webui_vite_dev = os.environ.get('SOULSYNC_WEBUI_VITE_DEV', '').lower() in ('1', 'true', 'yes', 'on')
_webui_vite_url = os.environ.get('SOULSYNC_WEBUI_VITE_URL', 'http://127.0.0.1:5173').rstrip('/')
_webui_vite_base = '/static/dist/'
_webui_react_apps = {
'router': {
'entry': 'src/app/main.tsx',
'dev_entry': 'src/app/main.tsx',
'mount_id': None,
'mount_class': None,
},
}
_webui_client_route_map = {
'/dashboard': 'dashboard',
'/sync': 'sync',
'/search': 'search',
'/discover': 'discover',
'/playlist-explorer': 'playlist-explorer',
'/watchlist': 'watchlist',
'/wishlist': 'wishlist',
'/automations': 'automations',
'/active-downloads': 'active-downloads',
'/library': 'library',
'/tools': 'tools',
'/artist-detail': 'artist-detail',
'/stats': 'stats',
'/import': 'import',
'/settings': 'settings',
'/issues': 'issues',
'/help': 'help',
'/hydrabase': 'hydrabase',
}
_webui_react_page_ids = {'issues'}
def _resolve_webui_initial_page(pathname: str) -> str | None:
normalized = pathname.rstrip('/') or '/'
return _webui_client_route_map.get(normalized)
def _resolve_webui_initial_nav_page(page_id: str | None) -> str | None:
if page_id is None:
return None
if page_id == 'artist-detail':
return 'library'
return page_id
def _resolve_webui_initial_react_page(page_id: str | None) -> str | None:
if page_id in _webui_react_page_ids:
return page_id
return None
def _should_serve_webui_spa(pathname: str) -> bool:
normalized = pathname.rstrip('/') or '/'
excluded_exact_paths = {'/callback', '/status'}
excluded_prefixes = (
'/api',
'/auth',
'/callback/',
'/deezer/',
'/socket.io',
'/static',
'/tidal/',
)
if normalized in excluded_exact_paths:
return False
return not normalized.startswith(excluded_prefixes)
def _load_webui_react_manifest():
global _webui_react_manifest, _webui_react_manifest_mtime
manifest_mtime = None
if _webui_react_manifest_path.exists():
try:
manifest_mtime = _webui_react_manifest_path.stat().st_mtime
except OSError:
manifest_mtime = None
if _webui_react_manifest is not None and manifest_mtime == _webui_react_manifest_mtime:
return _webui_react_manifest
if _webui_react_manifest_path.exists():
try:
with _webui_react_manifest_path.open('r', encoding='utf-8') as handle:
_webui_react_manifest = json.load(handle)
_webui_react_manifest_mtime = manifest_mtime
except Exception as exc:
logger.warning(f"Failed to load webui manifest: {exc}")
_webui_react_manifest = {}
_webui_react_manifest_mtime = manifest_mtime
else:
_webui_react_manifest = {}
_webui_react_manifest_mtime = None
return _webui_react_manifest
def _build_webui_react_assets(app_name='issues', placement='body'):
app_config = _webui_react_apps.get(app_name)
if not app_config:
return ''
if placement not in ('head', 'body'):
return ''
if _webui_vite_dev:
if placement == 'head':
return ''
vite_base_url = f'{_webui_vite_url}{_webui_vite_base.rstrip("/")}'
return '\n'.join([
f'<script type="module" src="{vite_base_url}/@vite/client"></script>',
f'<script type="module" src="{vite_base_url}/{app_config["dev_entry"]}"></script>',
])
manifest = _load_webui_react_manifest()
entry = manifest.get(app_config['entry'])
if not entry:
return ''
if placement == 'head':
head_assets = []
for css_file in entry.get('css', []):
head_assets.append(f'<link rel="stylesheet" href="{url_for("static", filename=f"dist/{css_file}")}">')
return '\n'.join(head_assets)
entry_file = entry.get('file')
return f'<script type="module" src="{url_for("static", filename=f"dist/{entry_file}")}"></script>'
def _build_webui_react_root(app_name='issues'):
app_config = _webui_react_apps.get(app_name)
if not app_config:
return ''
mount_id = app_config.get('mount_id', f'{app_name}-app-root')
mount_class = app_config.get('mount_class', f'{app_name}-app-root')
if not mount_id:
return ''
return f'<div id="{mount_id}" class="{mount_class}" data-react-app="{app_name}"></div>'
@app.context_processor
def inject_webui_assets():
return {
'react_assets_for': lambda app_name='issues', placement='body': _build_webui_react_assets(app_name, placement),
'react_root': lambda app_name='issues': _build_webui_react_root(app_name),
# Transitional aliases for older templates while the route contract settles.
'react_head_assets': lambda app_name='issues': _build_webui_react_assets(app_name, 'head'),
'react_body_assets': lambda app_name='issues': _build_webui_react_assets(app_name, 'body'),
'react_mount': lambda app_name='issues': _build_webui_react_root(app_name),
}
# --- Profile Context (before_request hook) ---
@app.before_request
@ -514,7 +674,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."""
@ -3301,7 +3480,13 @@ from core.connection_detect import run_detection
@app.route('/')
def index():
return render_template('index.html')
initial_page = _resolve_webui_initial_page(request.path)
return render_template(
'index.html',
initial_client_page=initial_page,
initial_nav_page=_resolve_webui_initial_nav_page(initial_page),
initial_react_page=_resolve_webui_initial_react_page(initial_page),
)
@app.route('/sw.js')
@ -3325,9 +3510,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 ---

4
webui/.gitignore vendored Normal file
View file

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

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) }}">
{{ react_assets_for('router', 'head')|safe }}
</head>
<body>
@ -103,9 +104,12 @@
<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>
<option value="tools">Tools</option>
<option value="stats">Listening Stats</option>
<option value="playlist-explorer">Playlist Explorer</option>
<option value="import">Import</option>
@ -117,9 +121,12 @@
<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>
<label><input type="checkbox" value="tools" checked> Tools</label>
<label><input type="checkbox" value="stats" checked> Listening Stats</label>
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
<label><input type="checkbox" value="import" checked> Import</label>
@ -189,75 +196,75 @@
<!-- Navigation Section -->
<nav class="sidebar-nav">
<button class="nav-button active" data-page="dashboard">
<button class="nav-button{% if initial_nav_page == 'dashboard' %} active{% endif %}" 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>
<button class="nav-button" data-page="sync">
<button class="nav-button{% if initial_nav_page == 'sync' %} active{% endif %}" data-page="sync">
<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"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></span>
<span class="nav-text">Sync</span>
</button>
<button class="nav-button" data-page="search">
<button class="nav-button{% if initial_nav_page == 'search' %} active{% endif %}" data-page="search">
<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"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><polyline points="8 11 11 14 14 11"/></svg></span>
<span class="nav-text">Search</span>
</button>
<button class="nav-button" data-page="discover">
<button class="nav-button{% if initial_nav_page == 'discover' %} active{% endif %}" data-page="discover">
<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"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
<span class="nav-text">Discover</span>
</button>
<button class="nav-button" data-page="playlist-explorer">
<button class="nav-button{% if initial_nav_page == 'playlist-explorer' %} active{% endif %}" data-page="playlist-explorer">
<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"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="18"/><line x1="12" y1="12" x2="19" y2="18"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><line x1="12" y1="12" x2="12" y2="18"/><circle cx="12" cy="19" r="2"/></svg></span>
<span class="nav-text">Explorer</span>
</button>
<button class="nav-button" data-page="watchlist">
<button class="nav-button{% if initial_nav_page == 'watchlist' %} active{% endif %}" data-page="watchlist">
<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"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-text">Watchlist</span>
<span class="dl-nav-badge hidden" id="watchlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="wishlist">
<button class="nav-button{% if initial_nav_page == 'wishlist' %} active{% endif %}" data-page="wishlist">
<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"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="nav-text">Wishlist</span>
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
</button>
<button class="nav-button" data-page="automations">
<button class="nav-button{% if initial_nav_page == 'automations' %} active{% endif %}" data-page="automations">
<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"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
</button>
<button class="nav-button" data-page="active-downloads">
<button class="nav-button{% if initial_nav_page == 'active-downloads' %} active{% endif %}" data-page="active-downloads">
<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
</button>
<button class="nav-button" data-page="import">
<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"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<button class="nav-button{% if initial_nav_page == 'import' %} active{% endif %}" data-page="import">
<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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Import</span>
</button>
<button class="nav-button" data-page="library">
<button class="nav-button{% if initial_nav_page == 'library' %} active{% endif %}" data-page="library">
<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"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
<span class="nav-text">Library</span>
</button>
<button class="nav-button" data-page="tools">
<button class="nav-button{% if initial_nav_page == 'tools' %} active{% endif %}" data-page="tools">
<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"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
<span class="nav-text">Tools</span>
</button>
<button class="nav-button" data-page="stats">
<button class="nav-button{% if initial_nav_page == 'stats' %} active{% endif %}" data-page="stats">
<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"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></span>
<span class="nav-text">Stats</span>
</button>
<button class="nav-button" data-page="settings">
<button class="nav-button{% if initial_nav_page == 'settings' %} active{% endif %}" data-page="settings">
<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"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="nav-text">Settings</span>
</button>
<button class="nav-button" data-page="issues">
<button class="nav-button{% if initial_nav_page == 'issues' %} active{% endif %}" data-page="issues">
<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"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span class="nav-text">Issues</span>
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
</button>
<button class="nav-button" data-page="help">
<button class="nav-button{% if initial_nav_page == 'help' %} active{% endif %}" data-page="help">
<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"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span class="nav-text">Help & Docs</span>
</button>
<button class="nav-button" data-page="hydrabase" id="hydrabase-nav" style="display: none;">
<button class="nav-button{% if initial_nav_page == 'hydrabase' %} active{% endif %}" data-page="hydrabase" id="hydrabase-nav" style="display: none;">
<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"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></span>
<span class="nav-text">Hydrabase</span>
</button>
@ -301,9 +308,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{% if initial_react_page %} active{% endif %}" data-react-app="router"></div>
<!-- Dashboard Page -->
<div class="page active" id="dashboard-page">
<div class="page{% if initial_client_page == 'dashboard' %} active{% endif %}" id="dashboard-page">
<div class="dashboard-container">
<div class="dashboard-header">
<div class="header-text">
@ -834,7 +842,7 @@
</div>
<!-- Main container for the Sync page -->
<div class="page" id="sync-page">
<div class="page{% if initial_client_page == 'sync' %} active{% endif %}" id="sync-page">
<!-- Header -->
<div class="sync-header">
<div class="sync-header-row">
@ -1844,8 +1852,8 @@
</div>
</div>
<!-- Downloads Page -->
<div class="page" id="search-page">
<!-- Search Page -->
<div class="page{% if initial_client_page == 'search' %} active{% endif %}" id="search-page">
<!--
This top-level container replicates the QSplitter from downloads.py,
creating the two-panel layout for the page.
@ -2091,10 +2099,8 @@
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
<div class="page{% if initial_client_page == 'automations' %} active{% endif %}" id="automations-page">
<!-- List View -->
<div class="automations-list-view" id="automations-list-view">
<div class="automations-container">
@ -2145,65 +2151,59 @@
</div>
<!-- Active Downloads Page -->
<div class="page" id="active-downloads-page">
<div class="adl-layout">
<!-- Left: download list -->
<div class="adl-main">
<div class="adl-container">
<div class="adl-header">
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
<div class="adl-controls">
<div class="adl-filter-pills" id="adl-filter-pills">
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-cancel-all-btn" id="adl-cancel-all-btn" onclick="adlCancelAll()" style="display:none" title="Cancel all active and queued downloads">Cancel All</button>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
</div>
</div>
<div class="page{% if initial_client_page == 'active-downloads' %} active{% endif %}" id="active-downloads-page">
<div class="adl-container">
<div class="adl-header">
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
<div class="adl-controls">
<div class="adl-filter-pills" id="adl-filter-pills">
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
</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 style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
</div>
</div>
</div>
<!-- Right: batch context panel -->
<div class="adl-batch-panel" id="adl-batch-panel">
<div class="adl-batch-panel-header">
<h3 class="adl-batch-panel-title">Batches</h3>
<div class="adl-batch-panel-header-actions">
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Library.</div>
</div>
</div>
<!-- Right: batch context panel -->
<div class="adl-batch-panel" id="adl-batch-panel">
<div class="adl-batch-panel-header">
<h3 class="adl-batch-panel-title">Batches</h3>
<div class="adl-batch-panel-header-actions">
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<div class="adl-batch-active" id="adl-batch-active">
<!-- Active batch cards rendered by JS -->
</div>
<div class="adl-batch-history-section" id="adl-batch-history-section" style="display:none">
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
<span>Recent History</span>
<div class="adl-batch-history-header-actions">
<button class="library-history-btn" onclick="event.stopPropagation();openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
<div class="adl-batch-active" id="adl-batch-active">
<!-- Active batch cards rendered by JS -->
</div>
<div class="adl-batch-history-section" id="adl-batch-history-section" style="display:none">
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
<span>Recent History</span>
<div class="adl-batch-history-header-actions">
<button class="library-history-btn" onclick="event.stopPropagation();openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
<div class="adl-batch-history-list" id="adl-batch-history-list">
<!-- Completed batch history rendered by JS -->
</div>
<div class="adl-batch-history-list" id="adl-batch-history-list">
<!-- Completed batch history rendered by JS -->
</div>
</div>
</div>
</div>
<!-- Library Page -->
<div class="page" id="library-page">
<div class="page{% if initial_client_page == 'library' %} active{% endif %}" id="library-page">
<div class="library-container">
<!-- Header -->
<div class="library-header">
@ -2349,7 +2349,7 @@
</div>
<!-- Artist Detail Page -->
<div class="page" id="artist-detail-page">
<div class="page{% if initial_client_page == 'artist-detail' %} active{% endif %}" id="artist-detail-page">
<div class="page-header">
<button class="back-btn" id="artist-detail-back-btn">
<span>← Back to Library</span>
@ -2545,7 +2545,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">
@ -2677,7 +2677,7 @@
</div>
<!-- Discover Page -->
<div class="page" id="discover-page">
<div class="page{% if initial_client_page == 'discover' %} active{% endif %}" id="discover-page">
<div class="discover-container">
<!-- Hero Section -->
<div class="discover-hero">
@ -3456,7 +3456,7 @@
</div>
<!-- Playlist Explorer Page -->
<div class="page" id="playlist-explorer-page">
<div class="page{% if initial_client_page == 'playlist-explorer' %} active{% endif %}" id="playlist-explorer-page">
<div class="explorer-container">
<!-- Header (compact) -->
<div class="dashboard-header" style="margin-bottom: 12px;">
@ -3559,7 +3559,7 @@
</div>
<!-- Settings Page -->
<div class="page" id="settings-page">
<div class="page{% if initial_client_page == 'settings' %} active{% endif %}" id="settings-page">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title"><img src="/static/settings.png" class="page-header-icon" alt=""><span>Settings</span></h2>
@ -5756,7 +5756,7 @@
</div>
<!-- Hydrabase Page (Dev Mode Only) -->
<div class="page" id="hydrabase-page">
<div class="page{% if initial_client_page == 'hydrabase' %} active{% endif %}" id="hydrabase-page">
<div class="page-header">
<h2><img src="/static/hydrabase.png?v=1" class="page-header-icon" alt=""><span>Hydrabase</span></h2>
<div style="display: flex; align-items: center; gap: 12px;">
@ -5854,7 +5854,7 @@
</div>
<!-- Stats Page -->
<div class="page" id="stats-page">
<div class="page{% if initial_client_page == 'stats' %} active{% endif %}" id="stats-page">
<div class="stats-container">
<div class="stats-header">
<div class="stats-header-title">
@ -5995,7 +5995,7 @@
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="page{% if initial_client_page == 'import' %} active{% endif %}" id="import-page">
<div class="import-page-container">
<!-- Header with staging info -->
<div class="import-page-header">
@ -6145,52 +6145,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 -->
@ -6210,24 +6164,8 @@
</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 -->
<div class="page" id="help-page">
<div class="page{% if initial_client_page == 'help' %} active{% endif %}" id="help-page">
<div class="docs-layout">
<nav class="docs-sidebar" id="docs-sidebar">
<div class="docs-sidebar-header">
@ -6247,7 +6185,7 @@
<!-- ═══════════════════════════════════════════════════════════════════
TOOLS PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="tools-page">
<div class="page{% if initial_client_page == 'tools' %} active{% endif %}" id="tools-page">
<div class="tools-page-container">
<div class="tools-page-header">
<div class="tools-page-header-left">
@ -6684,7 +6622,7 @@
<!-- ═══════════════════════════════════════════════════════════════════
WATCHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="watchlist-page">
<div class="page{% if initial_client_page == 'watchlist' %} active{% endif %}" id="watchlist-page">
<div class="watchlist-page-container">
<!-- Header -->
<div class="watchlist-page-header">
@ -6800,7 +6738,7 @@
<!-- ═══════════════════════════════════════════════════════════════════
WISHLIST PAGE
═══════════════════════════════════════════════════════════════════ -->
<div class="page" id="wishlist-page">
<div class="page{% if initial_client_page == 'wishlist' %} active{% endif %}" id="wishlist-page">
<div class="wishlist-page-container">
<!-- Header -->
<div class="wishlist-page-header">
@ -7851,6 +7789,8 @@
<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>
{{ react_assets_for('router', 'body')|safe }}
{{ react_assets_for('issues', '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 -->
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>

5392
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
webui/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "soulsync-webui",
"private": true,
"type": "module",
"scripts": {
"dev": "vp dev",
"build": "vp build",
"test": "vp test run",
"test:watch": "vp test",
"test:e2e": "playwright test"
},
"dependencies": {
"@tanstack/react-query": "^5.96.2",
"@tanstack/react-router": "^1.168.10",
"ky": "^2.0.0-0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.12",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^29.0.1",
"typescript": "^6.0.2",
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vite-plus": "latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
},
"packageManager": "npm@11.12.1",
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
}
}

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,27 @@
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 parseJsonResponse<T>(promise: ResponsePromise<T>): Promise<T> {
try {
return (await promise.json()) as T;
} catch (error) {
if (error instanceof HTTPError) {
const payload = (await error.response.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error || `Request failed with status ${error.response.status}`);
}
throw error;
}
}

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 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 };
}
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,138 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { 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 './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 {
getCurrentPageId: vi.fn<() => ShellPageId>(() => 'dashboard'),
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(() => {
window.SoulSyncIssueActions = {};
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
window.SoulSyncWebShellBridge = undefined;
window.SoulSyncIssueActions = 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();
vi.stubGlobal('fetch', mockIssuesFetch());
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(window.SoulSyncWebShellBridge?.activateLegacyPath).toHaveBeenCalledWith('/discover');
});
expect(history.location.pathname).toBe('/discover');
});
});

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,57 @@
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 type ShellBridge = NonNullable<typeof window.SoulSyncWebShellBridge>;
export function getShellBridge(): ShellBridge | null {
return window.SoulSyncWebShellBridge ?? null;
}
export function getShellProfileContext(bridge = getShellBridge()): ShellProfileContext | null {
return bridge?.getCurrentProfileContext() ?? null;
}
export function getProfileHomePath(bridge = getShellBridge()): `/${string}` {
const pageId = bridge?.getProfileHomePage() ?? 'discover';
return getShellRouteByPageId(pageId)?.path ?? '/discover';
}
export function bindWindowWebRouter(router: AnyRouter) {
window.SoulSyncWebRouter = {
routeManifest: [...shellRouteManifest],
getCurrentPageId() {
return resolveShellPageFromPath(window.location.pathname);
},
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 };

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

@ -0,0 +1,34 @@
import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './bridge';
declare global {
interface Window {
SoulSyncIssueActions?: {
addToWishlist?: (albumId: string, artistName: string, albumName: string) => void;
downloadAlbum?: (albumId: string, artistName: string, albumName: string) => void;
};
SoulSyncWebRouter?: {
routeManifest: ShellRouteDefinition[];
getCurrentPageId: () => ShellPageId | null;
getCurrentPath: () => string;
resolvePageId: (pathname: string) => ShellPageId | null;
navigateToPage: (
pageId: ShellPageId,
options?: {
replace?: boolean;
},
) => Promise<boolean>;
};
SoulSyncWebShellBridge?: {
getCurrentPageId: () => ShellPageId;
getCurrentProfileContext: () => ShellProfileContext;
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,55 @@
import { useRouter } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { getProfileHomePath, getShellBridge, type ShellPageId } from './bridge';
export const ROUTER_ROOT_ID = 'webui-react-root';
export const SHELL_BRIDGE_READY_EVENT = 'ss:webui-shell-bridge-ready';
export function useShellBridge() {
const [ready, setReady] = useState(() => Boolean(getShellBridge()));
useEffect(() => {
const handleReady = () => {
setReady(Boolean(getShellBridge()));
};
handleReady();
window.addEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
return () => {
window.removeEventListener(SHELL_BRIDGE_READY_EVENT, handleReady);
};
}, []);
return ready ? getShellBridge() : null;
}
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();
useEffect(() => {
if (!bridge) return;
if (!bridge.isPageAllowed(pageId)) {
void router.navigate({ href: getProfileHomePath(bridge), replace: true });
return;
}
bridge.setActivePageChrome(pageId);
bridge.showReactHost(pageId);
}, [bridge, pageId, router]);
return bridge;
}

View file

@ -0,0 +1,55 @@
import { describe, expect, it } from 'vite-plus/test';
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: '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: 'import', path: '/import', 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,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,7 @@
import { Outlet, createRootRouteWithContext } from '@tanstack/react-router';
import type { AppRouterContext } from '@/app/router';
export const Route = createRootRouteWithContext<AppRouterContext>()({
component: () => <Outlet />,
});

View file

@ -0,0 +1,11 @@
import { createFileRoute } from '@tanstack/react-router';
import { LegacyRouteController } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/')({
component: IndexRouteComponent,
});
function IndexRouteComponent() {
return <LegacyRouteController pathname="/" />;
}

View file

@ -0,0 +1,288 @@
import { queryOptions } from '@tanstack/react-query';
import { apiClient, parseJsonResponse } from '@/app/api-client';
import type {
IssueCounts,
IssueCountsResponse,
IssueDetailResponse,
IssueListResponse,
IssueRecord,
IssuesSearch,
IssueSnapshot,
} from './-issues.types';
const DEFAULT_LIMIT = 100;
export const REFRESH_EVENT = 'ss:issues-refresh';
export const CLOSE_EVENT = 'ss:issues-close-detail';
export const DEFAULT_ISSUES_SEARCH: Required<IssuesSearch> = {
status: 'open',
category: 'all',
};
export const ISSUE_CATEGORY_META: Record<
string,
{ label: string; icon: string; description: string; applies: Array<'track' | 'album' | 'artist'> }
> = {
wrong_track: {
label: 'Wrong Track',
icon: 'XT',
description: 'This file plays a different song than expected',
applies: ['track'],
},
wrong_metadata: {
label: 'Wrong Metadata',
icon: 'MD',
description: 'Title, artist, year, or other tags are incorrect',
applies: ['track', 'album'],
},
wrong_cover: {
label: 'Wrong Cover Art',
icon: 'CA',
description: 'The artwork is wrong or missing',
applies: ['album'],
},
wrong_artist: {
label: 'Wrong Artist',
icon: 'AR',
description: 'This track is filed under the wrong artist',
applies: ['track'],
},
duplicate_tracks: {
label: 'Duplicate Tracks',
icon: 'DT',
description: 'The same track appears more than once in this album',
applies: ['album'],
},
missing_tracks: {
label: 'Missing Tracks',
icon: 'MT',
description: 'Tracks that should be here are missing',
applies: ['album'],
},
audio_quality: {
label: 'Audio Quality',
icon: 'AQ',
description: 'Audio has quality issues like clipping or low bitrate',
applies: ['track'],
},
wrong_album: {
label: 'Wrong Album',
icon: 'AL',
description: 'This track belongs to a different album',
applies: ['track'],
},
incomplete_album: {
label: 'Incomplete Album',
icon: 'IA',
description: 'Album is partially downloaded',
applies: ['album'],
},
other: {
label: 'Other',
icon: 'OT',
description: 'Any other issue not listed above',
applies: ['track', 'album', 'artist'],
},
};
export const ISSUE_STATUS_META: Record<string, { 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' },
};
function createIssueHeaders(profileId: number, extra?: HeadersInit): Headers {
const headers = new Headers(extra);
headers.set('X-Profile-Id', String(profileId || 1));
return headers;
}
export function normalizeIssuesSearch(search: IssuesSearch | undefined): Required<IssuesSearch> {
const status = search?.status;
const category = search?.category;
return {
status:
status === 'all' ||
status === 'open' ||
status === 'in_progress' ||
status === 'resolved' ||
status === 'dismissed'
? status
: DEFAULT_ISSUES_SEARCH.status,
category: typeof category === 'string' && category.length > 0 ? category : 'all',
};
}
export async function fetchIssueCounts(profileId: number): Promise<IssueCounts> {
const payload = await parseJsonResponse<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: Required<IssuesSearch>,
): 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 parseJsonResponse<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 parseJsonResponse<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 parseJsonResponse<{ 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 deleteIssue(profileId: number, issueId: number): Promise<void> {
const payload = await parseJsonResponse<{ 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', 'counts', profileId],
queryFn: () => fetchIssueCounts(profileId),
});
}
export function issueListQueryOptions(profileId: number, search: Required<IssuesSearch>) {
return queryOptions({
queryKey: ['issues', 'list', profileId, search.status, search.category],
queryFn: () => fetchIssueList(profileId, search),
});
}
export function issueDetailQueryOptions(profileId: number, issueId: number) {
return queryOptions({
queryKey: ['issues', 'detail', profileId, issueId],
queryFn: () => fetchIssue(profileId, issueId),
enabled: issueId > 0,
});
}
export function dispatchIssuesRefreshEvent() {
window.dispatchEvent(new CustomEvent(REFRESH_EVENT));
}
export function dispatchIssuesCloseEvent() {
window.dispatchEvent(new CustomEvent(CLOSE_EVENT));
}
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 ISSUE_STATUS_META[status]?.label || status.replace(/_/g, ' ');
}
export function getPriorityClassName(priority: string): 'high' | 'low' | 'normal' {
if (priority === 'high') return 'high';
if (priority === 'low') return 'low';
return 'normal';
}

View file

@ -0,0 +1,68 @@
export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'dismissed';
export interface IssueSnapshot {
[key: string]: unknown;
title?: string;
name?: string;
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;
}
export interface IssueRecord {
id: number;
profile_id: number;
entity_type: 'track' | 'album' | 'artist';
entity_id: string;
category: string;
title: string;
description?: string | null;
status: IssueStatus | string;
priority: 'low' | 'normal' | 'high' | 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 IssuesSearch {
category?: string;
status?: IssueStatus | 'all';
}

View file

@ -0,0 +1,148 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test';
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 {
getCurrentPageId: vi.fn<() => ShellPageId>(() => 'issues'),
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'downloads'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
function renderIssuesRoute() {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries: ['/issues'] });
const router = createAppRouter({ history, queryClient });
return render(<AppRouterProvider router={router} queryClient={queryClient} />);
}
const shellActions = {
downloadAlbum: vi.fn(),
addToWishlist: vi.fn(),
};
describe('issues route', () => {
beforeEach(() => {
shellActions.downloadAlbum.mockReset();
shellActions.addToWishlist.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',
},
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',
},
created_at: '2026-04-03 10:30:00',
reporter_name: 'Ada',
},
});
}
return createResponse({ success: true });
}) as unknown as typeof fetch,
);
vi.stubGlobal('SoulSyncIssueActions', shellActions);
});
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('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 () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7'));
fireEvent.click(screen.getByRole('button', { name: /close issue detail/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
});
it('invokes the legacy adapter for admin downloads', async () => {
renderIssuesRoute();
fireEvent.click(await screen.findByTestId('issue-card-7'));
fireEvent.click(await screen.findByRole('button', { name: /download album/i }));
expect(shellActions.downloadAlbum).toHaveBeenCalledWith('abc123', 'Artist', 'Album Name');
});
});

View file

@ -0,0 +1,889 @@
.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;
}
.issuesFilterSelect {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
background-image: 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;
background-position: right 10px center;
padding-right: 28px;
min-width: 130px;
}
.issuesFilterSelect:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.issuesFilterSelect option,
.issuesFilterSelect optgroup {
background: #1a1a2e;
color: #fff;
}
.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);
}
.issueDetailResponseTextarea {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
resize: vertical;
min-height: 70px;
font-family: inherit;
transition: border-color 0.2s;
box-sizing: border-box;
}
.issueDetailResponseTextarea:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
}
.modalFooter {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
padding: 0 22px 22px;
}
.modalButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.modalButton:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.modalButtonSecondary {
background: rgba(255, 255, 255, 0.04);
}
.modalButtonPrimary {
background: linear-gradient(
135deg,
rgba(var(--accent-rgb), 0.95),
rgba(var(--accent-light-rgb), 0.85)
);
color: #08110b;
border-color: transparent;
font-weight: 700;
}
.modalButtonDanger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
color: #ffd0d0;
}
.modalButtonGhost {
background: rgba(255, 255, 255, 0.04);
}
.modalButtonProgress {
background: rgba(77, 166, 255, 0.15);
color: #4da6ff;
border-color: rgba(77, 166, 255, 0.25);
}
.modalButtonResolve {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
border-color: rgba(74, 222, 128, 0.25);
}
.modalButtonDismiss {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
}
.modalButtonReopen {
background: rgba(var(--accent-light-rgb), 0.15);
color: #fff;
border-color: rgba(var(--accent-light-rgb), 0.3);
}
.modalButtonDelete {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
}
.modalButtonDelete:hover:not(:disabled),
.modalButtonDanger:hover:not(:disabled),
.modalButtonProgress:hover:not(:disabled),
.modalButtonResolve:hover:not(:disabled),
.modalButtonDismiss:hover:not(:disabled),
.modalButtonReopen:hover:not(:disabled) {
filter: brightness(1.08);
}
@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;
}
.issuesFilterSelect {
min-width: 100%;
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;
}
.modal {
max-height: calc(100vh - 24px);
border-radius: 22px;
}
.modalBody {
padding: 18px;
}
.modalFooter {
padding: 0 18px 18px;
}
.issueDetailMetaGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.issueMetaValue {
max-width: 80px;
}
.issueActionButtons {
justify-content: center;
}
}

View file

@ -0,0 +1,403 @@
import { useMutation } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import type { IssueRecord } from '../-issues.types';
import {
deleteIssue,
formatIssueDate,
formatStatusLabel,
getEntityDetails,
getEntityLabel,
getIssueArtwork,
ISSUE_CATEGORY_META,
parseSnapshot,
updateIssue,
} from '../-issues.helpers';
import styles from './issue-detail-modal.module.css';
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;
}
export function IssueDetailModal({
error,
isAdmin,
isLoading,
issue,
onClose,
onMutationSuccess,
profileId,
}: {
error: unknown;
isAdmin: boolean;
isLoading: boolean;
issue: IssueRecord | null;
onClose: () => void;
onMutationSuccess: () => void;
profileId: number;
}) {
const [adminResponse, setAdminResponse] = useState('');
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 statusButtons = useMemo(() => {
if (!issue) return null;
if (isAdmin) {
if (issue.status === 'open' || issue.status === 'in_progress') {
return (
<>
{issue.status === 'open' && (
<button
className={`${styles.modalButton} ${styles.modalButtonProgress}`}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'in_progress',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Mark In Progress
</button>
)}
<button
className={`${styles.modalButton} ${styles.modalButtonResolve}`}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'resolved',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Resolve
</button>
<button
className={`${styles.modalButton} ${styles.modalButtonDismiss}`}
type="button"
onClick={() =>
updateMutation.mutate({
issueId: issue.id,
status: 'dismissed',
adminResponse,
})
}
disabled={updateMutation.isPending}
>
Dismiss
</button>
</>
);
}
return (
<button
className={`${styles.modalButton} ${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.modalButton} ${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 && !isLoading && !error) {
return null;
}
const snapshot = issue ? parseSnapshot(issue.snapshot_data) : {};
const issueDetails = issue ? getEntityDetails(issue, snapshot) : [];
const issueArtwork = getIssueArtwork(snapshot);
const issueCategoryLabel = issue
? ISSUE_CATEGORY_META[issue.category]?.label || issue.category
: '';
const downloadAlbum = window.SoulSyncIssueActions?.downloadAlbum;
const addToWishlist = window.SoulSyncIssueActions?.addToWishlist;
return (
<div
className={styles.modalOverlay}
role="dialog"
aria-modal="true"
aria-labelledby="issue-detail-title"
onClick={onClose}
>
<div
className={`${styles.modal} ${styles.issueDetailModal}`}
onClick={(event) => event.stopPropagation()}
>
<div className={styles.modalHeader}>
<h3 className={styles.modalHeaderTitle} id="issue-detail-title">
{issue ? `Issue #${issue.id}` : 'Issue details'}
</h3>
<button
className={styles.modalClose}
type="button"
onClick={onClose}
aria-label="Close issue detail"
>
&times;
</button>
</div>
<div className={styles.modalBody}>
{isLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issue details...
</div>
) : error ? (
<div className={styles.issuesEmpty}>
<div className={styles.issuesEmptyTitle}>Failed to load issue</div>
<div className={styles.issuesEmptyText}>
{error instanceof Error ? error.message : 'Unknown error'}
</div>
</div>
) : issue ? (
<>
<div className={styles.issueHero}>
<div className={styles.issueHeroArtGroup}>
{issue.entity_type === 'artist' && issueArtwork ? (
<img className={styles.issueHeroArtistThumb} src={issueArtwork} alt="" />
) : null}
{issueArtwork ? (
<img className={styles.issueHeroAlbumArt} src={issueArtwork} alt="" />
) : (
<div className={styles.issueHeroAlbumPlaceholder}>
{ISSUE_CATEGORY_META[issue.category]?.icon || 'OT'}
</div>
)}
</div>
<div className={styles.issueHeroInfo}>
{issue.entity_type !== 'artist' && snapshot.artist_name ? (
<div className={styles.issueHeroArtist}>{String(snapshot.artist_name)}</div>
) : null}
<div className={styles.issueHeroAlbum}>
{String(snapshot.album_title || snapshot.title || issue.title)}
</div>
{issue.entity_type === 'track' ? (
<div className={styles.issueHeroTrackName}> {issue.title}</div>
) : null}
{issue.entity_type === 'artist' && snapshot.name ? (
<div className={styles.issueHeroTrackName}>{String(snapshot.name)}</div>
) : null}
{issueDetails.length > 0 ? (
<div className={styles.issueHeroMeta}>{issueDetails.join(' · ')}</div>
) : null}
</div>
</div>
<div className={styles.issueDetailInfoBar}>
<div className={styles.issueDetailInfoLeft}>
<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}>
Created {formatIssueDate(issue.created_at)}
</span>
{issue.reporter_name ? (
<span className={styles.issueDetailProfile}>by {issue.reporter_name}</span>
) : null}
</div>
</div>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Issue Details</div>
<div className={styles.issueDetailTitleText}>{issue.title}</div>
<div
className={
issue.description ? styles.issueDetailDescription : styles.issueDetailNoDesc
}
>
{issue.description || 'No additional details provided'}
</div>
</div>
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Context</div>
<div className={styles.issueDetailMetaGrid}>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Entity</span>
<span className={styles.issueMetaValue}>
{getEntityLabel(issue.entity_type)}
</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>#</span>
<span className={styles.issueMetaLabel}>Entity ID</span>
<span className={styles.issueMetaValue}>{issue.entity_id}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Category</span>
<span className={styles.issueMetaValue}>
{ISSUE_CATEGORY_META[issue.category]?.label || issue.category}
</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}>!</span>
<span className={styles.issueMetaLabel}>Priority</span>
<span className={styles.issueMetaValue}>{issue.priority}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Status</span>
<span className={styles.issueMetaValue}>{formatStatusLabel(issue.status)}</span>
</div>
<div className={styles.issueMetaItem}>
<span className={styles.issueMetaIcon}></span>
<span className={styles.issueMetaLabel}>Created</span>
<span className={styles.issueMetaValue}>
{formatIssueDate(issue.created_at)}
</span>
</div>
</div>
</div>
{issue.entity_type !== 'artist' && isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Actions</div>
<div className={styles.issueActionButtons}>
{downloadAlbum && (
<button
className={`${styles.issueActionButton} ${styles.issueActionDownload}`}
type="button"
onClick={() =>
downloadAlbum(
String(snapshot.spotify_album_id || ''),
String(snapshot.artist_name || ''),
String(snapshot.album_title || snapshot.title || ''),
)
}
>
Download Album
</button>
)}
{addToWishlist && (
<button
className={`${styles.issueActionButton} ${styles.issueActionWishlist}`}
type="button"
onClick={() =>
addToWishlist(
String(snapshot.spotify_album_id || ''),
String(snapshot.artist_name || ''),
String(snapshot.album_title || snapshot.title || ''),
)
}
>
Add to Wishlist
</button>
)}
</div>
</div>
)}
{isAdmin && (
<div className={styles.issueDetailSection}>
<div className={styles.issueDetailSectionTitle}>Admin Response</div>
<textarea
className={styles.issueDetailResponseTextarea}
value={adminResponse}
onChange={(event) => setAdminResponse(event.target.value)}
placeholder="Write a response to the reporter..."
/>
</div>
)}
</>
) : null}
</div>
<div className={styles.modalFooter}>
<button
className={`${styles.modalButton} ${styles.modalButtonSecondary}`}
type="button"
onClick={onClose}
>
Close
</button>
{!isLoading && !error && issue && (
<>
{statusButtons}
{isAdmin && (
<button
className={`${styles.modalButton} ${styles.modalButtonDelete}`}
type="button"
onClick={() => {
if (window.confirm('Delete this issue?')) {
deleteMutation.mutate(issue.id);
}
}}
disabled={deleteMutation.isPending}
>
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,889 @@
.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;
}
.issuesFilterSelect {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
outline: none;
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
background-image: 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;
background-position: right 10px center;
padding-right: 28px;
min-width: 130px;
}
.issuesFilterSelect:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
}
.issuesFilterSelect option,
.issuesFilterSelect optgroup {
background: #1a1a2e;
color: #fff;
}
.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);
}
.issueDetailResponseTextarea {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
outline: none;
resize: vertical;
min-height: 70px;
font-family: inherit;
transition: border-color 0.2s;
box-sizing: border-box;
}
.issueDetailResponseTextarea:focus {
border-color: rgba(var(--accent-light-rgb), 0.5);
}
.modalFooter {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
padding: 0 22px 22px;
}
.modalButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.modalButton:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.modalButtonSecondary {
background: rgba(255, 255, 255, 0.04);
}
.modalButtonPrimary {
background: linear-gradient(
135deg,
rgba(var(--accent-rgb), 0.95),
rgba(var(--accent-light-rgb), 0.85)
);
color: #08110b;
border-color: transparent;
font-weight: 700;
}
.modalButtonDanger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
color: #ffd0d0;
}
.modalButtonGhost {
background: rgba(255, 255, 255, 0.04);
}
.modalButtonProgress {
background: rgba(77, 166, 255, 0.15);
color: #4da6ff;
border-color: rgba(77, 166, 255, 0.25);
}
.modalButtonResolve {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
border-color: rgba(74, 222, 128, 0.25);
}
.modalButtonDismiss {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
}
.modalButtonReopen {
background: rgba(var(--accent-light-rgb), 0.15);
color: #fff;
border-color: rgba(var(--accent-light-rgb), 0.3);
}
.modalButtonDelete {
background: rgba(239, 68, 68, 0.14);
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.28);
}
.modalButtonDelete:hover:not(:disabled),
.modalButtonDanger:hover:not(:disabled),
.modalButtonProgress:hover:not(:disabled),
.modalButtonResolve:hover:not(:disabled),
.modalButtonDismiss:hover:not(:disabled),
.modalButtonReopen:hover:not(:disabled) {
filter: brightness(1.08);
}
@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;
}
.issuesFilterSelect {
min-width: 100%;
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;
}
.modal {
max-height: calc(100vh - 24px);
border-radius: 22px;
}
.modalBody {
padding: 18px;
}
.modalFooter {
padding: 0 18px 18px;
}
.issueDetailMetaGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.issueMetaValue {
max-width: 80px;
}
.issueActionButtons {
justify-content: center;
}
}

View file

@ -0,0 +1,338 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { getShellProfileContext } from '@/platform/shell/bridge';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type { IssueCounts, IssueRecord, IssueStatus } from '../-issues.types';
import {
CLOSE_EVENT,
REFRESH_EVENT,
dispatchIssuesRefreshEvent,
getEntityDetails,
getEntityLabel,
getEntityName,
getIssueArtwork,
getPriorityClassName,
issueCountsQueryOptions,
issueDetailQueryOptions,
issueListQueryOptions,
ISSUE_CATEGORY_META,
ISSUE_STATUS_META,
normalizeIssuesSearch,
parseSnapshot,
formatIssueDate,
} from '../-issues.helpers';
import { Route } from '../route';
import { IssueDetailModal } from './issue-detail-modal';
import styles from './issues-page.module.css';
export function IssuesPage() {
const bridge = useReactPageShell('issues');
const queryClient = useQueryClient();
const navigate = useNavigate({ from: Route.fullPath });
const search = Route.useSearch();
const normalizedSearch = normalizeIssuesSearch(search);
const [selectedIssueId, setSelectedIssueId] = useState<number | null>(null);
const profile = getShellProfileContext(bridge);
const profileId = profile?.profileId ?? 0;
useEffect(() => {
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ['issues'] });
};
const handleClose = () => {
setSelectedIssueId(null);
};
window.addEventListener(REFRESH_EVENT, handleRefresh);
window.addEventListener(CLOSE_EVENT, handleClose);
return () => {
window.removeEventListener(REFRESH_EVENT, handleRefresh);
window.removeEventListener(CLOSE_EVENT, handleClose);
};
}, [queryClient]);
const countsQuery = useQuery({
...issueCountsQueryOptions(profileId),
enabled: profileId > 0,
});
const issuesQuery = useQuery({
...issueListQueryOptions(profileId, normalizedSearch),
enabled: profileId > 0,
});
const selectedIssueQuery = useQuery({
...issueDetailQueryOptions(profileId, selectedIssueId ?? 0),
enabled: profileId > 0 && selectedIssueId !== null,
});
if (!bridge || !profile || !bridge.isPageAllowed('issues')) {
return null;
}
return (
<>
<IssueBoard
categoryFilter={normalizedSearch.category}
counts={countsQuery.data}
isAdmin={profile.isAdmin}
issues={issuesQuery.data?.issues ?? []}
issuesError={issuesQuery.error}
issuesLoading={issuesQuery.isLoading}
onCategoryChange={(category) =>
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, category }),
replace: true,
})
}
onIssueSelect={setSelectedIssueId}
onStatusChange={(status) =>
void navigate({
search: (prev) => normalizeIssuesSearch({ ...prev, status }),
replace: true,
})
}
statusFilter={normalizedSearch.status}
/>
<IssueDetailModal
isAdmin={profile.isAdmin}
issue={selectedIssueQuery.data ?? null}
isLoading={selectedIssueQuery.isLoading}
error={selectedIssueQuery.error}
onClose={() => setSelectedIssueId(null)}
onMutationSuccess={() => {
setSelectedIssueId(null);
dispatchIssuesRefreshEvent();
}}
profileId={profile.profileId}
/>
</>
);
}
function IssueBoard({
categoryFilter,
counts,
isAdmin,
issues,
issuesError,
issuesLoading,
onCategoryChange,
onIssueSelect,
onStatusChange,
statusFilter,
}: {
categoryFilter: string;
counts: IssueCounts | undefined;
isAdmin: boolean;
issues: IssueRecord[];
issuesError: unknown;
issuesLoading: boolean;
onCategoryChange: (category: string) => void;
onIssueSelect: (issueId: number) => void;
onStatusChange: (status: IssueStatus | 'all') => void;
statusFilter: IssueStatus | 'all';
}) {
const safeCounts = counts ?? {
open: 0,
in_progress: 0,
resolved: 0,
dismissed: 0,
total: 0,
};
return (
<div className={styles.issuesContainer} data-testid="issues-board">
<div className={styles.issuesHeader}>
<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"
className={styles.issuesFilterSelect}
aria-label="Status"
value={statusFilter}
onChange={(event) => onStatusChange(event.target.value as IssueStatus | 'all')}
>
<option value="open">Open</option>
<option value="all">All Statuses</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select
id="issues-filter-category"
className={styles.issuesFilterSelect}
aria-label="Category"
value={categoryFilter}
onChange={(event) => onCategoryChange(event.target.value)}
>
<option value="all">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 className={styles.issuesStats} data-testid="issue-counts">
<div className={`${styles.issuesStatCard} ${styles.issuesStatOpen}`}>
<div className={styles.issuesStatNumber}>{safeCounts.open}</div>
<div className={styles.issuesStatLabel}>Open</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatProgress}`}>
<div className={styles.issuesStatNumber}>{safeCounts.in_progress}</div>
<div className={styles.issuesStatLabel}>In Progress</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatResolved}`}>
<div className={styles.issuesStatNumber}>{safeCounts.resolved}</div>
<div className={styles.issuesStatLabel}>Resolved</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatDismissed}`}>
<div className={styles.issuesStatNumber}>{safeCounts.dismissed}</div>
<div className={styles.issuesStatLabel}>Dismissed</div>
</div>
<div className={`${styles.issuesStatCard} ${styles.issuesStatTotal}`}>
<div className={styles.issuesStatNumber}>{safeCounts.total}</div>
<div className={styles.issuesStatLabel}>Total</div>
</div>
</div>
{issuesLoading ? (
<div className={styles.issuesLoading}>
<div className={styles.issuesSpinner} />
Loading issues...
</div>
) : issuesError ? (
<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>
) : issues.length === 0 ? (
<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>
) : (
<div className={styles.issuesList} data-testid="issue-list">
{issues.map((issue) => {
const snapshot = parseSnapshot(issue.snapshot_data);
const artwork = getIssueArtwork(snapshot);
const entityName = getEntityName(issue, snapshot);
const details = getEntityDetails(issue, snapshot);
const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open;
const catMeta = ISSUE_CATEGORY_META[issue.category] || ISSUE_CATEGORY_META.other;
const priorityVariant = getPriorityClassName(issue.priority);
const statusClassName =
issue.status === 'in_progress'
? styles.issueStatusProgress
: issue.status === 'resolved'
? styles.issueStatusResolved
: issue.status === 'dismissed'
? styles.issueStatusDismissed
: styles.issueStatusOpen;
const priorityClass =
priorityVariant === 'high'
? styles.issuePriorityHigh
: priorityVariant === 'low'
? styles.issuePriorityLow
: styles.issuePriorityNormal;
const createdDate = formatIssueDate(issue.created_at);
return (
<button
key={issue.id}
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>
{issue.admin_response ? (
<span className={styles.issueCardResponded} title="Admin has responded">
💬
</span>
) : null}
</div>
<div className={styles.issueCardEntity}>
<span className={styles.issueCardEntityType}>
{getEntityLabel(issue.entity_type)}
</span>
<span className={styles.issueCardEntityName}>{entityName}</span>
{details.length > 0 ? (
<span className={styles.issueCardMetaLine}>{details.join(' - ')}</span>
) : null}
</div>
{issue.description ? (
<div className={styles.issueCardDescription}>{issue.description}</div>
) : null}
<div className={styles.issueCardFooter}>
<span className={styles.issueCardDate}>{createdDate}</span>
{isAdmin && issue.reporter_name ? (
<span className={styles.issueCardProfile}>by {issue.reporter_name}</span>
) : null}
</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>
);
})}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
import { getProfileHomePath, getShellProfileContext } from '@/platform/shell/bridge';
import {
issueCountsQueryOptions,
issueListQueryOptions,
normalizeIssuesSearch,
} from './-issues.helpers';
import { IssuesPage } from './-ui/issues-page';
export const Route = createFileRoute('/issues')({
validateSearch: normalizeIssuesSearch,
beforeLoad: ({ context }) => {
const bridge = context.platform.getShellBridge();
if (bridge && !bridge.isPageAllowed('issues')) {
throw redirect({ href: getProfileHomePath(bridge), replace: true });
}
},
loaderDeps: ({ search }) => search,
loader: async ({ context, deps }) => {
const profile = getShellProfileContext(context.platform.getShellBridge());
if (!profile) return;
await Promise.all([
context.queryClient.ensureQueryData(issueCountsQueryOptions(profile.profileId)),
context.queryClient.ensureQueryData(issueListQueryOptions(profile.profileId, deps)),
]);
},
component: IssuesPage,
});

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

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

View file

@ -100,6 +100,26 @@ const HELPER_CONTENT = {
],
docsId: 'library'
},
'.nav-button[data-page="watchlist"]': {
title: 'Watchlist',
description: 'Artists you follow for new releases. Use this page to scan for fresh albums and singles, manage per-artist preferences, and keep discovery data up to date.',
tips: [
'Watchlist scans add matching releases to your Wishlist',
'The page keeps live scan activity visible while jobs run',
'Per-artist settings control which release types are included'
],
docsId: 'art-watchlist'
},
'.nav-button[data-page="wishlist"]': {
title: 'Wishlist',
description: 'Tracks waiting to be downloaded. Failed downloads, watchlist discoveries, and manual adds all land here for batch processing.',
tips: [
'Album and single queues are shown separately',
'Use batch select to remove or download multiple items',
'The page shows the next automatic processing cycle'
],
docsId: 'art-wishlist'
},
'.nav-button[data-page="active-downloads"]': {
title: 'Downloads',
description: 'Centralized view of every download across the entire app. Shows live status for all tracks from Sync, Discover, Artists, Search, and Wishlist in one place.',
@ -109,6 +129,16 @@ const HELPER_CONTENT = {
'Clear Completed button removes finished items from the list'
]
},
'.nav-button[data-page="tools"]': {
title: 'Tools',
description: 'Database and maintenance operations live here now: scans, backups, cleanup jobs, cache work, and repair tools.',
tips: [
'Library Maintenance is the hero section at the top of the page',
'Use this page for scans, backups, and repair jobs',
'The dashboard keeps only a compact link card'
],
docsId: 'dashboard'
},
'.nav-button[data-page="playlist-explorer"]': {
title: 'Playlist Explorer',
description: 'Visual exploration tool for playlists. Browse album art grids or full discographies from any playlist source. Select tracks to add to wishlist or download directly.',
@ -208,7 +238,7 @@ const HELPER_CONTENT = {
'#watchlist-button': {
title: 'Watchlist',
description: 'Artists you\'re following for new releases. SoulSync periodically scans for new albums and singles from these artists and adds them to your Wishlist for download.',
description: 'Artists you\'re following for new releases. Click to open the Watchlist page, where SoulSync scans for new albums and singles and sends matches to your Wishlist for download.',
tips: [
'Add artists from the Artists page or Library page',
'Badge shows total watched artist count',
@ -219,10 +249,10 @@ const HELPER_CONTENT = {
},
'#wishlist-button': {
title: 'Wishlist',
description: 'Tracks queued for download. Failed downloads, watchlist new releases, and manually added tracks all land here. Process the wishlist to retry downloads.',
description: 'Tracks queued for download. Click to open the Wishlist page, where failed downloads, watchlist discoveries, and manual adds all land for retry.',
tips: [
'Badge shows total wishlist track count',
'Click to open the wishlist modal with all pending tracks',
'Use the page to manage all pending tracks in one place',
'Process All starts downloading every wishlist item',
'Tracks can be added manually or arrive from failed batch downloads'
],

77268
webui/static/script.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -8368,9 +8368,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
padding: 20px 22px;
position: relative;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
inset 0 1px 0 rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15);
display: flex;
flex-direction: column;
gap: 8px;
@ -49183,6 +49181,7 @@ tr.tag-diff-same {
transition: border-color 0.2s;
appearance: none;
-webkit-appearance: none;
color-scheme: dark;
background-image: 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;
background-position: right 10px center;
@ -49199,6 +49198,11 @@ tr.tag-diff-same {
color: #fff;
}
.issues-filter-select optgroup {
background: #1a1a2e;
color: #fff;
}
/* Issues Stats */
.issues-stats {
@ -55343,7 +55347,7 @@ tr.tag-diff-same {
.explorer-node-artist { width: 56px; height: 56px; }
.explorer-node-album { width: 60px; height: 60px; }
}
display: flex;
/* display: flex;
align-items: center;
gap: 20px;
padding: 24px 32px;
@ -55355,7 +55359,7 @@ tr.tag-diff-same {
backdrop-filter: blur(16px);
animation: explorer-node-enter 0.6s ease both;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
} */
.explorer-root-glow {
position: absolute;

View file

@ -0,0 +1,91 @@
import { expect, test, type Page } from '@playwright/test';
import { shellRouteManifest, type ShellPageId } from '../src/platform/shell/route-manifest';
async function waitForShellRoute(page: Page, pageId: string) {
if (pageId === 'issues') {
await expect
.poll(async () =>
page.evaluate(() => document.querySelector('.page.active')?.id ?? ''),
)
.toBe('webui-react-root');
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 'library';
}
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;
}
for (const route of shellRouteManifest) {
await page.goto(new URL(route.path, baseURL).toString(), { waitUntil: 'domcontentloaded' });
await waitForShellRoute(page, route.pageId);
await expect(page).toHaveURL(expectedUrlPattern(route.path));
await expectNavHighlight(page, route.pageId);
if (route.pageId === 'issues') {
await verifyIssuesRoute(page);
}
}
});
test('browser history restores top-level routes', async ({ page, baseURL }) => {
if (!baseURL) {
test.skip();
return;
}
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)?$/);
});

23
webui/tsconfig.json Normal file
View file

@ -0,0 +1,23 @@
{
"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", "./playwright.config.ts"],
"references": []
}

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

@ -0,0 +1,55 @@
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import { defineConfig } from 'vite-plus';
export default defineConfig({
fmt: {
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,
},
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')],
},
},
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,
},
});

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

@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';