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 urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
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 flask_socketio import SocketIO, emit, join_room, leave_room
|
||||||
from utils.logging_config import get_logger, setup_logging
|
from utils.logging_config import get_logger, setup_logging
|
||||||
from utils.async_helpers import run_async
|
from utils.async_helpers import run_async
|
||||||
|
|
@ -371,6 +371,166 @@ def _log_rejected_socketio_origin():
|
||||||
request.headers.get('X-Forwarded-Proto', ''),
|
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) ---
|
# --- Profile Context (before_request hook) ---
|
||||||
@app.before_request
|
@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)
|
return metadata_registry.get_spotify_client_for_profile(profile_id)
|
||||||
|
|
||||||
# Valid page IDs for profile permission validation
|
# 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():
|
def check_download_permission():
|
||||||
"""Check if current profile has download permission. Returns error response or None if allowed."""
|
"""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('/')
|
@app.route('/')
|
||||||
def index():
|
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')
|
@app.route('/sw.js')
|
||||||
|
|
@ -3325,9 +3510,9 @@ def service_worker():
|
||||||
@app.route('/<path:page>')
|
@app.route('/<path:page>')
|
||||||
def spa_catch_all(page):
|
def spa_catch_all(page):
|
||||||
# Serve index.html for client-side routes; let Flask handle real routes first.
|
# 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)
|
abort(404)
|
||||||
return render_template('index.html')
|
return index()
|
||||||
|
|
||||||
# --- API Endpoints ---
|
# --- 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='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='mobile.css', v=static_v) }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -103,9 +104,12 @@
|
||||||
<option value="sync">Sync</option>
|
<option value="sync">Sync</option>
|
||||||
<option value="search">Search</option>
|
<option value="search">Search</option>
|
||||||
<option value="discover">Discover</option>
|
<option value="discover">Discover</option>
|
||||||
|
<option value="watchlist">Watchlist</option>
|
||||||
|
<option value="wishlist">Wishlist</option>
|
||||||
<option value="automations">Automations</option>
|
<option value="automations">Automations</option>
|
||||||
<option value="active-downloads">Downloads</option>
|
<option value="active-downloads">Downloads</option>
|
||||||
<option value="library">Library</option>
|
<option value="library">Library</option>
|
||||||
|
<option value="tools">Tools</option>
|
||||||
<option value="stats">Listening Stats</option>
|
<option value="stats">Listening Stats</option>
|
||||||
<option value="playlist-explorer">Playlist Explorer</option>
|
<option value="playlist-explorer">Playlist Explorer</option>
|
||||||
<option value="import">Import</option>
|
<option value="import">Import</option>
|
||||||
|
|
@ -117,9 +121,12 @@
|
||||||
<label><input type="checkbox" value="sync" checked> Sync</label>
|
<label><input type="checkbox" value="sync" checked> Sync</label>
|
||||||
<label><input type="checkbox" value="search" checked> Search</label>
|
<label><input type="checkbox" value="search" checked> Search</label>
|
||||||
<label><input type="checkbox" value="discover" checked> Discover</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="automations" checked> Automations</label>
|
||||||
<label><input type="checkbox" value="active-downloads" checked> Downloads</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="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="stats" checked> Listening Stats</label>
|
||||||
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
|
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
|
||||||
<label><input type="checkbox" value="import" checked> Import</label>
|
<label><input type="checkbox" value="import" checked> Import</label>
|
||||||
|
|
@ -189,75 +196,75 @@
|
||||||
|
|
||||||
<!-- Navigation Section -->
|
<!-- Navigation Section -->
|
||||||
<nav class="sidebar-nav">
|
<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-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>
|
<span class="nav-text">Dashboard</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Sync</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Search</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Discover</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Explorer</span>
|
||||||
</button>
|
</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-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="nav-text">Watchlist</span>
|
||||||
<span class="dl-nav-badge hidden" id="watchlist-nav-badge">0</span>
|
<span class="dl-nav-badge hidden" id="watchlist-nav-badge">0</span>
|
||||||
</button>
|
</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-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="nav-text">Wishlist</span>
|
||||||
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
|
<span class="dl-nav-badge hidden" id="wishlist-nav-badge">0</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Automations</span>
|
||||||
</button>
|
</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-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="nav-text">Downloads</span>
|
||||||
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
|
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-button" data-page="import">
|
<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="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>
|
<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>
|
<span class="nav-text">Import</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Library</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Tools</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Stats</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Settings</span>
|
||||||
</button>
|
</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-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="nav-text">Issues</span>
|
||||||
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
|
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Help & Docs</span>
|
||||||
</button>
|
</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-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>
|
<span class="nav-text">Hydrabase</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -301,9 +308,10 @@
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
<!-- Global particle canvas for page background animations -->
|
<!-- Global particle canvas for page background animations -->
|
||||||
<canvas id="page-particles-canvas"></canvas>
|
<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 -->
|
<!-- 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-container">
|
||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
|
|
@ -834,7 +842,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main container for the Sync page -->
|
<!-- 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 -->
|
<!-- Header -->
|
||||||
<div class="sync-header">
|
<div class="sync-header">
|
||||||
<div class="sync-header-row">
|
<div class="sync-header-row">
|
||||||
|
|
@ -1844,8 +1852,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Downloads Page -->
|
<!-- Search Page -->
|
||||||
<div class="page" id="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,
|
This top-level container replicates the QSplitter from downloads.py,
|
||||||
creating the two-panel layout for the page.
|
creating the two-panel layout for the page.
|
||||||
|
|
@ -2091,10 +2099,8 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Automations Page -->
|
<!-- Automations Page -->
|
||||||
<div class="page" id="automations-page">
|
<div class="page{% if initial_client_page == 'automations' %} active{% endif %}" id="automations-page">
|
||||||
<!-- List View -->
|
<!-- List View -->
|
||||||
<div class="automations-list-view" id="automations-list-view">
|
<div class="automations-list-view" id="automations-list-view">
|
||||||
<div class="automations-container">
|
<div class="automations-container">
|
||||||
|
|
@ -2145,65 +2151,59 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Downloads Page -->
|
<!-- Active Downloads Page -->
|
||||||
<div class="page" id="active-downloads-page">
|
<div class="page{% if initial_client_page == 'active-downloads' %} active{% endif %}" id="active-downloads-page">
|
||||||
<div class="adl-layout">
|
<div class="adl-container">
|
||||||
<!-- Left: download list -->
|
<div class="adl-header">
|
||||||
<div class="adl-main">
|
<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-container">
|
<div class="adl-controls">
|
||||||
<div class="adl-header">
|
<div class="adl-filter-pills" id="adl-filter-pills">
|
||||||
<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>
|
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
|
||||||
<div class="adl-controls">
|
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
|
||||||
<div class="adl-filter-pills" id="adl-filter-pills">
|
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
|
||||||
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
|
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
|
||||||
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
|
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</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>
|
</div>
|
||||||
<div class="adl-list" id="adl-list">
|
<div style="display:flex;align-items:center;gap:10px;">
|
||||||
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Right: batch context panel -->
|
<div class="adl-list" id="adl-list">
|
||||||
<div class="adl-batch-panel" id="adl-batch-panel">
|
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Library.</div>
|
||||||
<div class="adl-batch-panel-header">
|
</div>
|
||||||
<h3 class="adl-batch-panel-title">Batches</h3>
|
</div>
|
||||||
<div class="adl-batch-panel-header-actions">
|
<!-- Right: batch context panel -->
|
||||||
<button class="library-history-btn" onclick="openLibraryHistoryModal()" title="View full download + import history">Download History</button>
|
<div class="adl-batch-panel" id="adl-batch-panel">
|
||||||
<button class="adl-batch-panel-collapse" id="adl-batch-collapse" onclick="adlToggleBatchPanel()" title="Toggle batch panel">
|
<div class="adl-batch-panel-header">
|
||||||
<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>
|
<h3 class="adl-batch-panel-title">Batches</h3>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
<div class="adl-batch-active" id="adl-batch-active">
|
<div class="adl-batch-history-list" id="adl-batch-history-list">
|
||||||
<!-- Active batch cards rendered by JS -->
|
<!-- Completed batch history 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Library Page -->
|
<!-- 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">
|
<div class="library-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="library-header">
|
<div class="library-header">
|
||||||
|
|
@ -2349,7 +2349,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Artist Detail Page -->
|
<!-- 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">
|
<div class="page-header">
|
||||||
<button class="back-btn" id="artist-detail-back-btn">
|
<button class="back-btn" id="artist-detail-back-btn">
|
||||||
<span>← Back to Library</span>
|
<span>← Back to Library</span>
|
||||||
|
|
@ -2545,7 +2545,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Similar Artists Section (works for both library and source artists).
|
<!-- 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. -->
|
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-section" id="ad-similar-artists-section">
|
||||||
<div class="similar-artists-header">
|
<div class="similar-artists-header">
|
||||||
|
|
@ -2677,7 +2677,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Discover Page -->
|
<!-- 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">
|
<div class="discover-container">
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<div class="discover-hero">
|
<div class="discover-hero">
|
||||||
|
|
@ -3456,7 +3456,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Playlist Explorer Page -->
|
<!-- 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">
|
<div class="explorer-container">
|
||||||
<!-- Header (compact) -->
|
<!-- Header (compact) -->
|
||||||
<div class="dashboard-header" style="margin-bottom: 12px;">
|
<div class="dashboard-header" style="margin-bottom: 12px;">
|
||||||
|
|
@ -3559,7 +3559,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Page -->
|
<!-- 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="dashboard-header">
|
||||||
<div class="header-text">
|
<div class="header-text">
|
||||||
<h2 class="header-title"><img src="/static/settings.png" class="page-header-icon" alt=""><span>Settings</span></h2>
|
<h2 class="header-title"><img src="/static/settings.png" class="page-header-icon" alt=""><span>Settings</span></h2>
|
||||||
|
|
@ -5756,7 +5756,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hydrabase Page (Dev Mode Only) -->
|
<!-- 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">
|
<div class="page-header">
|
||||||
<h2><img src="/static/hydrabase.png?v=1" class="page-header-icon" alt=""><span>Hydrabase</span></h2>
|
<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;">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
|
@ -5854,7 +5854,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Page -->
|
<!-- 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-container">
|
||||||
<div class="stats-header">
|
<div class="stats-header">
|
||||||
<div class="stats-header-title">
|
<div class="stats-header-title">
|
||||||
|
|
@ -5995,7 +5995,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Import Page -->
|
<!-- 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">
|
<div class="import-page-container">
|
||||||
<!-- Header with staging info -->
|
<!-- Header with staging info -->
|
||||||
<div class="import-page-header">
|
<div class="import-page-header">
|
||||||
|
|
@ -6145,52 +6145,6 @@
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Report Issue Modal -->
|
<!-- Report Issue Modal -->
|
||||||
|
|
@ -6210,24 +6164,8 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Issue Detail Modal (Admin) -->
|
|
||||||
<div class="modal-overlay hidden" id="issue-detail-overlay">
|
|
||||||
<div class="enhanced-bulk-modal issue-detail-modal">
|
|
||||||
<div class="enhanced-bulk-modal-header">
|
|
||||||
<h3 id="issue-detail-title">Issue Details</h3>
|
|
||||||
<button class="enhanced-bulk-modal-close" onclick="closeIssueDetailModal()">×</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 -->
|
<!-- 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">
|
<div class="docs-layout">
|
||||||
<nav class="docs-sidebar" id="docs-sidebar">
|
<nav class="docs-sidebar" id="docs-sidebar">
|
||||||
<div class="docs-sidebar-header">
|
<div class="docs-sidebar-header">
|
||||||
|
|
@ -6247,7 +6185,7 @@
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
TOOLS PAGE
|
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-container">
|
||||||
<div class="tools-page-header">
|
<div class="tools-page-header">
|
||||||
<div class="tools-page-header-left">
|
<div class="tools-page-header-left">
|
||||||
|
|
@ -6684,7 +6622,7 @@
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
WATCHLIST PAGE
|
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">
|
<div class="watchlist-page-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="watchlist-page-header">
|
<div class="watchlist-page-header">
|
||||||
|
|
@ -6800,7 +6738,7 @@
|
||||||
<!-- ═══════════════════════════════════════════════════════════════════
|
<!-- ═══════════════════════════════════════════════════════════════════
|
||||||
WISHLIST PAGE
|
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">
|
<div class="wishlist-page-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="wishlist-page-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="{{ 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>
|
<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>
|
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
|
||||||
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
|
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
|
||||||
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
|
<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'
|
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"]': {
|
'.nav-button[data-page="active-downloads"]': {
|
||||||
title: '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.',
|
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'
|
'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"]': {
|
'.nav-button[data-page="playlist-explorer"]': {
|
||||||
title: '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.',
|
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': {
|
'#watchlist-button': {
|
||||||
title: 'Watchlist',
|
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: [
|
tips: [
|
||||||
'Add artists from the Artists page or Library page',
|
'Add artists from the Artists page or Library page',
|
||||||
'Badge shows total watched artist count',
|
'Badge shows total watched artist count',
|
||||||
|
|
@ -219,10 +249,10 @@ const HELPER_CONTENT = {
|
||||||
},
|
},
|
||||||
'#wishlist-button': {
|
'#wishlist-button': {
|
||||||
title: 'Wishlist',
|
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: [
|
tips: [
|
||||||
'Badge shows total wishlist track count',
|
'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',
|
'Process All starts downloading every wishlist item',
|
||||||
'Tracks can be added manually or arrive from failed batch downloads'
|
'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;
|
padding: 20px 22px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.15);
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -49183,6 +49181,7 @@ tr.tag-diff-same {
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-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-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-repeat: no-repeat;
|
||||||
background-position: right 10px center;
|
background-position: right 10px center;
|
||||||
|
|
@ -49199,6 +49198,11 @@ tr.tag-diff-same {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.issues-filter-select optgroup {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Issues Stats */
|
/* Issues Stats */
|
||||||
|
|
||||||
.issues-stats {
|
.issues-stats {
|
||||||
|
|
@ -55343,7 +55347,7 @@ tr.tag-diff-same {
|
||||||
.explorer-node-artist { width: 56px; height: 56px; }
|
.explorer-node-artist { width: 56px; height: 56px; }
|
||||||
.explorer-node-album { width: 60px; height: 60px; }
|
.explorer-node-album { width: 60px; height: 60px; }
|
||||||
}
|
}
|
||||||
display: flex;
|
/* display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 24px 32px;
|
padding: 24px 32px;
|
||||||
|
|
@ -55355,7 +55359,7 @@ tr.tag-diff-same {
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
animation: explorer-node-enter 0.6s ease both;
|
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);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
}
|
} */
|
||||||
|
|
||||||
.explorer-root-glow {
|
.explorer-root-glow {
|
||||||
position: absolute;
|
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