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:
parent
63f313d0c2
commit
d98dcd8606
36 changed files with 86948 additions and 159 deletions
195
web_server.py
195
web_server.py
|
|
@ -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
4
webui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.vite
|
||||
node_modules
|
||||
static/dist
|
||||
test-results
|
||||
232
webui/index.html
232
webui/index.html
|
|
@ -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()">×</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
5392
webui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
39
webui/package.json
Normal file
39
webui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
webui/playwright.config.ts
Normal file
13
webui/playwright.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
27
webui/src/app/api-client.ts
Normal file
27
webui/src/app/api-client.ts
Normal 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
23
webui/src/app/main.tsx
Normal 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();
|
||||
13
webui/src/app/query-client.ts
Normal file
13
webui/src/app/query-client.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
138
webui/src/app/router.test.tsx
Normal file
138
webui/src/app/router.test.tsx
Normal 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
82
webui/src/app/router.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
webui/src/platform/shell/bridge.ts
Normal file
57
webui/src/platform/shell/bridge.ts
Normal 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
34
webui/src/platform/shell/globals.d.ts
vendored
Normal 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 {};
|
||||
55
webui/src/platform/shell/route-controllers.tsx
Normal file
55
webui/src/platform/shell/route-controllers.tsx
Normal 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;
|
||||
}
|
||||
55
webui/src/platform/shell/route-manifest.test.ts
Normal file
55
webui/src/platform/shell/route-manifest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
80
webui/src/platform/shell/route-manifest.ts
Normal file
80
webui/src/platform/shell/route-manifest.ts
Normal 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;
|
||||
}
|
||||
95
webui/src/routeTree.gen.ts
Normal file
95
webui/src/routeTree.gen.ts
Normal 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
12
webui/src/routes/$.tsx
Normal 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}`} />;
|
||||
}
|
||||
7
webui/src/routes/__root.tsx
Normal file
7
webui/src/routes/__root.tsx
Normal 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 />,
|
||||
});
|
||||
11
webui/src/routes/index.tsx
Normal file
11
webui/src/routes/index.tsx
Normal 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="/" />;
|
||||
}
|
||||
288
webui/src/routes/issues/-issues.helpers.ts
Normal file
288
webui/src/routes/issues/-issues.helpers.ts
Normal 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';
|
||||
}
|
||||
68
webui/src/routes/issues/-issues.types.ts
Normal file
68
webui/src/routes/issues/-issues.types.ts
Normal 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';
|
||||
}
|
||||
148
webui/src/routes/issues/-route.test.tsx
Normal file
148
webui/src/routes/issues/-route.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
889
webui/src/routes/issues/-ui/issue-detail-modal.module.css
Normal file
889
webui/src/routes/issues/-ui/issue-detail-modal.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
403
webui/src/routes/issues/-ui/issue-detail-modal.tsx
Normal file
403
webui/src/routes/issues/-ui/issue-detail-modal.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
}
|
||||
889
webui/src/routes/issues/-ui/issues-page.module.css
Normal file
889
webui/src/routes/issues/-ui/issues-page.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
338
webui/src/routes/issues/-ui/issues-page.tsx
Normal file
338
webui/src/routes/issues/-ui/issues-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
webui/src/routes/issues/route.tsx
Normal file
31
webui/src/routes/issues/route.tsx
Normal 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
1
webui/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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
77268
webui/static/script.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
|
|||
91
webui/tests/issues.smoke.spec.ts
Normal file
91
webui/tests/issues.smoke.spec.ts
Normal 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
23
webui/tsconfig.json
Normal 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
55
webui/vite.config.ts
Normal 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
1
webui/vitest.setup.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
Loading…
Reference in a new issue