From e1a5bf678a7686dbb18e7bfe29de2346200170ba Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 13 Mar 2026 21:14:57 -0700 Subject: [PATCH] Add library issue reporting system with actionable detail modal --- database/music_database.py | 194 +++++ web_server.py | 335 ++++++++- webui/index.html | 84 +++ webui/static/script.js | 1380 +++++++++++++++++++++++++++++++----- webui/static/style.css | 1363 +++++++++++++++++++++++++++++++++++ 5 files changed, 3163 insertions(+), 193 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index ec0e6e28..65b10ced 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -429,6 +429,32 @@ class MusicDatabase: self._add_automation_system_column(cursor) self._add_automation_then_actions_column(cursor) + # Library issues — user-reported problems with tracks/albums/artists + cursor.execute(""" + CREATE TABLE IF NOT EXISTS library_issues ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id INTEGER NOT NULL DEFAULT 1, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + category TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + snapshot_data TEXT DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'open', + priority TEXT NOT NULL DEFAULT 'normal', + admin_response TEXT, + resolved_by INTEGER, + resolved_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (profile_id) REFERENCES profiles (id) ON DELETE CASCADE + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_profile ON library_issues (profile_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_status ON library_issues (status)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_entity ON library_issues (entity_type, entity_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_issues_created ON library_issues (created_at)") + conn.commit() logger.info("Database initialized successfully") @@ -7832,6 +7858,174 @@ class MusicDatabase: logger.error(f"Error getting radio tracks for track {track_id}: {e}") return {'success': False, 'error': str(e)} + # ── Library Issues CRUD ── + + def create_issue(self, profile_id: int, entity_type: str, entity_id: str, + category: str, title: str, description: str = '', + snapshot_data: Dict = None, priority: str = 'normal') -> Dict[str, Any]: + """Create a new library issue report.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO library_issues + (profile_id, entity_type, entity_id, category, title, description, + snapshot_data, priority) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (profile_id, entity_type, entity_id, category, title, description, + json.dumps(snapshot_data or {}), priority)) + conn.commit() + return {'success': True, 'id': cursor.lastrowid} + except Exception as e: + logger.error(f"Error creating issue: {e}") + return {'success': False, 'error': str(e)} + + def get_issues(self, profile_id: int = None, status: str = None, + category: str = None, entity_type: str = None, + limit: int = 100, offset: int = 0, + is_admin: bool = False) -> Dict[str, Any]: + """Get issues with optional filters. Non-admin only sees own issues.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + conditions = [] + params = [] + + if not is_admin and profile_id: + conditions.append("i.profile_id = ?") + params.append(profile_id) + if status: + conditions.append("i.status = ?") + params.append(status) + if category: + conditions.append("i.category = ?") + params.append(category) + if entity_type: + conditions.append("i.entity_type = ?") + params.append(entity_type) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + + # Count total + cursor.execute(f"SELECT COUNT(*) FROM library_issues i {where}", params) + total = cursor.fetchone()[0] + + # Fetch issues with reporter profile info + cursor.execute(f""" + SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color, + p.avatar_url as reporter_avatar + FROM library_issues i + LEFT JOIN profiles p ON i.profile_id = p.id + {where} + ORDER BY + CASE i.status WHEN 'open' THEN 0 WHEN 'in_progress' THEN 1 ELSE 2 END, + CASE i.priority WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, + i.created_at DESC + LIMIT ? OFFSET ? + """, params + [limit, offset]) + + issues = [] + for row in cursor.fetchall(): + issue = dict(row) + try: + issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}')) + except (json.JSONDecodeError, TypeError): + issue['snapshot_data'] = {} + issues.append(issue) + + return {'success': True, 'issues': issues, 'total': total} + except Exception as e: + logger.error(f"Error getting issues: {e}") + return {'success': False, 'error': str(e), 'issues': [], 'total': 0} + + def get_issue(self, issue_id: int) -> Optional[Dict[str, Any]]: + """Get a single issue by ID with reporter info.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT i.*, p.name as reporter_name, p.avatar_color as reporter_color, + p.avatar_url as reporter_avatar + FROM library_issues i + LEFT JOIN profiles p ON i.profile_id = p.id + WHERE i.id = ? + """, (issue_id,)) + row = cursor.fetchone() + if not row: + return None + issue = dict(row) + try: + issue['snapshot_data'] = json.loads(issue.get('snapshot_data', '{}')) + except (json.JSONDecodeError, TypeError): + issue['snapshot_data'] = {} + return issue + except Exception as e: + logger.error(f"Error getting issue {issue_id}: {e}") + return None + + def update_issue(self, issue_id: int, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update an issue (admin response, status change, etc.).""" + allowed_fields = {'status', 'priority', 'admin_response', 'resolved_by', 'resolved_at', + 'title', 'description', 'category'} + valid = {k: v for k, v in updates.items() if k in allowed_fields} + if not valid: + return {'success': False, 'error': 'No valid fields to update'} + try: + with self._get_connection() as conn: + cursor = conn.cursor() + set_clause = ', '.join(f'{k} = ?' for k in valid) + values = list(valid.values()) + [issue_id] + cursor.execute( + f"UPDATE library_issues SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + values + ) + conn.commit() + if cursor.rowcount == 0: + return {'success': False, 'error': 'Issue not found'} + return {'success': True} + except Exception as e: + logger.error(f"Error updating issue {issue_id}: {e}") + return {'success': False, 'error': str(e)} + + def delete_issue(self, issue_id: int) -> Dict[str, Any]: + """Delete an issue (admin only).""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM library_issues WHERE id = ?", (issue_id,)) + conn.commit() + if cursor.rowcount == 0: + return {'success': False, 'error': 'Issue not found'} + return {'success': True} + except Exception as e: + logger.error(f"Error deleting issue {issue_id}: {e}") + return {'success': False, 'error': str(e)} + + def get_issue_counts(self, is_admin: bool = False, profile_id: int = None) -> Dict[str, int]: + """Get issue counts by status for badge display.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + profile_filter = "" + params = [] + if not is_admin and profile_id: + profile_filter = "WHERE profile_id = ?" + params = [profile_id] + cursor.execute(f""" + SELECT status, COUNT(*) as count + FROM library_issues + {profile_filter} + GROUP BY status + """, params) + counts = {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0} + for row in cursor.fetchall(): + counts[row['status']] = row['count'] + counts['total'] += row['count'] + return counts + except Exception as e: + logger.error(f"Error getting issue counts: {e}") + return {'open': 0, 'in_progress': 0, 'resolved': 0, 'dismissed': 0, 'total': 0} + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/web_server.py b/web_server.py index e29638ed..39746915 100644 --- a/web_server.py +++ b/web_server.py @@ -10194,6 +10194,334 @@ def get_reorganize_status(): return jsonify(state) +# ── Library Issues endpoints ── + +@app.route('/api/issues', methods=['GET']) +def list_issues(): + """List issues. Admin sees all; non-admin sees own only.""" + try: + database = get_database() + profile_id = request.headers.get('X-Profile-Id', '1') + try: + profile_id = int(profile_id) + except (ValueError, TypeError): + profile_id = 1 + + # Determine admin status + profile = database.get_profile(profile_id) + is_admin = profile.get('is_admin', False) if profile else False + + status = request.args.get('status') + category = request.args.get('category') + entity_type = request.args.get('entity_type') + try: + limit = min(200, max(1, int(request.args.get('limit', 100)))) + except (ValueError, TypeError): + limit = 100 + try: + offset = max(0, int(request.args.get('offset', 0))) + except (ValueError, TypeError): + offset = 0 + + result = database.get_issues( + profile_id=profile_id, + status=status, + category=category, + entity_type=entity_type, + limit=limit, + offset=offset, + is_admin=is_admin, + ) + # Fix Plex/Jellyfin relative thumb URLs in stored snapshots + for issue in result.get('issues', []): + snap = issue.get('snapshot_data') + if isinstance(snap, dict): + for key in ('thumb_url', 'artist_thumb', 'album_thumb'): + if snap.get(key): + snap[key] = fix_artist_image_url(snap[key]) or snap[key] + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/issues', methods=['POST']) +def create_issue(): + """Create a new library issue.""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + # Use header for profile_id (not body) to prevent spoofing + profile_id = request.headers.get('X-Profile-Id', '1') + try: + profile_id = int(profile_id) + except (ValueError, TypeError): + profile_id = 1 + entity_type = data.get('entity_type') + entity_id = data.get('entity_id') + category = data.get('category') + title = data.get('title', '').strip() + description = data.get('description', '').strip() + priority = data.get('priority', 'normal') + + if not entity_type or not entity_id or not category or not title: + return jsonify({"success": False, "error": "entity_type, entity_id, category, and title are required"}), 400 + + valid_types = ('artist', 'album', 'track') + if entity_type not in valid_types: + return jsonify({"success": False, "error": f"entity_type must be one of: {', '.join(valid_types)}"}), 400 + + valid_categories = ('wrong_track', 'wrong_metadata', 'wrong_cover', 'duplicate_tracks', + 'missing_tracks', 'audio_quality', 'wrong_artist', 'wrong_album', + 'incomplete_album', 'other') + if category not in valid_categories: + return jsonify({"success": False, "error": f"Invalid category: {category}"}), 400 + + # Build snapshot of the entity's current state + snapshot = _build_issue_snapshot(database, entity_type, str(entity_id)) + + result = database.create_issue( + profile_id=profile_id, + entity_type=entity_type, + entity_id=str(entity_id), + category=category, + title=title, + description=description, + snapshot_data=snapshot, + priority=priority, + ) + return jsonify(result), 201 if result.get('success') else 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/issues/', methods=['GET']) +def get_issue(issue_id): + """Get a single issue.""" + try: + database = get_database() + issue = database.get_issue(issue_id) + if not issue: + return jsonify({"success": False, "error": "Issue not found"}), 404 + # Fix Plex/Jellyfin relative thumb URLs in stored snapshot + snap = issue.get('snapshot_data') + if isinstance(snap, dict): + for key in ('thumb_url', 'artist_thumb', 'album_thumb'): + if snap.get(key): + snap[key] = fix_artist_image_url(snap[key]) or snap[key] + return jsonify({"success": True, "issue": issue}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/issues/', methods=['PUT']) +def update_issue(issue_id): + """Update an issue (admin: respond/resolve; user: edit own description).""" + try: + database = get_database() + data = request.get_json() + if not data: + return jsonify({"success": False, "error": "No data provided"}), 400 + + profile_id = request.headers.get('X-Profile-Id', '1') + try: + profile_id = int(profile_id) + except (ValueError, TypeError): + profile_id = 1 + + profile = database.get_profile(profile_id) + is_admin = profile.get('is_admin', False) if profile else False + + # Non-admin can only edit their own issue's title/description + if not is_admin: + issue = database.get_issue(issue_id) + if not issue: + return jsonify({"success": False, "error": "Issue not found"}), 404 + if issue['profile_id'] != profile_id: + return jsonify({"success": False, "error": "Not authorized"}), 403 + data = {k: v for k, v in data.items() if k in ('title', 'description')} + + # If resolving, stamp resolved_by and resolved_at + if data.get('status') in ('resolved', 'dismissed') and is_admin: + data['resolved_by'] = profile_id + from datetime import datetime + data['resolved_at'] = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + # If reopening, clear resolution metadata + elif data.get('status') in ('open', 'in_progress') and is_admin: + data['resolved_by'] = None + data['resolved_at'] = None + + result = database.update_issue(issue_id, data) + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/issues/', methods=['DELETE']) +def delete_issue(issue_id): + """Delete an issue (admin or issue owner).""" + try: + database = get_database() + profile_id = request.headers.get('X-Profile-Id', '1') + try: + profile_id = int(profile_id) + except (ValueError, TypeError): + profile_id = 1 + + profile = database.get_profile(profile_id) + is_admin = profile.get('is_admin', False) if profile else False + + if not is_admin: + issue = database.get_issue(issue_id) + if not issue: + return jsonify({"success": False, "error": "Issue not found"}), 404 + if issue['profile_id'] != profile_id: + return jsonify({"success": False, "error": "Not authorized"}), 403 + + result = database.delete_issue(issue_id) + return jsonify(result) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/issues/counts', methods=['GET']) +def get_issue_counts(): + """Get issue counts by status for badge display.""" + try: + database = get_database() + profile_id = request.headers.get('X-Profile-Id', '1') + try: + profile_id = int(profile_id) + except (ValueError, TypeError): + profile_id = 1 + profile = database.get_profile(profile_id) + is_admin = profile.get('is_admin', False) if profile else False + counts = database.get_issue_counts(is_admin=is_admin, profile_id=profile_id) + return jsonify({"success": True, "counts": counts}) + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 + + +def _build_issue_snapshot(database, entity_type, entity_id): + """Capture current state of the entity for the issue report.""" + snapshot = {} + try: + conn = database._get_connection() + cursor = conn.cursor() + + if entity_type == 'track': + cursor.execute(""" + SELECT t.id, t.title, t.track_number, t.duration, + t.file_path, t.bitrate, t.bpm, + t.spotify_track_id, t.musicbrainz_recording_id, t.deezer_id as track_deezer_id, + a.name as artist_name, a.id as artist_id, + a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id, + a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id, + a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb, + al.title as album_title, al.year, al.thumb_url as album_thumb, + al.id as album_id, al.spotify_album_id, al.musicbrainz_release_id, + al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id, + al.qobuz_id as album_qobuz_id, al.label, al.record_type, + al.track_count as album_track_count + FROM tracks t + JOIN artists a ON t.artist_id = a.id + JOIN albums al ON t.album_id = al.id + WHERE t.id = ? + """, (entity_id,)) + row = cursor.fetchone() + if row: + d = dict(row) + # Add format info if file exists + resolved = _resolve_library_file_path(d.get('file_path')) + if resolved: + ext = os.path.splitext(resolved)[1].lower().lstrip('.') + d['format'] = ext.upper() + d['quality'] = _get_audio_quality_string(resolved) + # Fix Plex/Jellyfin relative thumb URLs + if d.get('artist_thumb'): + d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb'] + if d.get('album_thumb'): + d['album_thumb'] = fix_artist_image_url(d['album_thumb']) or d['album_thumb'] + snapshot = d + + elif entity_type == 'album': + cursor.execute(""" + SELECT al.id, al.title, al.year, al.track_count, al.thumb_url, + al.genres, al.label, al.record_type, al.duration, + al.spotify_album_id, al.musicbrainz_release_id, + al.deezer_id as album_deezer_id, al.tidal_id as album_tidal_id, + al.qobuz_id as album_qobuz_id, al.upc, + a.name as artist_name, a.id as artist_id, + a.spotify_artist_id, a.musicbrainz_id as artist_musicbrainz_id, + a.deezer_id as artist_deezer_id, a.tidal_id as artist_tidal_id, + a.qobuz_id as artist_qobuz_id, a.thumb_url as artist_thumb + FROM albums al + JOIN artists a ON al.artist_id = a.id + WHERE al.id = ? + """, (entity_id,)) + row = cursor.fetchone() + if row: + d = dict(row) + # Fix Plex/Jellyfin relative thumb URLs + if d.get('thumb_url'): + d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url'] + if d.get('artist_thumb'): + d['artist_thumb'] = fix_artist_image_url(d['artist_thumb']) or d['artist_thumb'] + # Parse genres + if d.get('genres'): + try: + d['genres'] = json.loads(d['genres']) + except (json.JSONDecodeError, TypeError): + pass + # Get track listing with enriched data + cursor.execute(""" + SELECT id, title, track_number, duration, file_path, bitrate, + spotify_track_id, bpm + FROM tracks WHERE album_id = ? ORDER BY track_number + """, (entity_id,)) + tracks_list = [] + for r in cursor.fetchall(): + td = dict(r) + # Add format from file extension + if td.get('file_path'): + resolved = _resolve_library_file_path(td['file_path']) + if resolved: + ext = os.path.splitext(resolved)[1].lower().lstrip('.') + td['format'] = ext.upper() + tracks_list.append(td) + d['tracks'] = tracks_list + snapshot = d + + elif entity_type == 'artist': + cursor.execute(""" + SELECT id, name, thumb_url, genres, summary, + spotify_artist_id, musicbrainz_id as artist_musicbrainz_id, + deezer_id as artist_deezer_id, tidal_id as artist_tidal_id, + qobuz_id as artist_qobuz_id + FROM artists WHERE id = ? + """, (entity_id,)) + row = cursor.fetchone() + if row: + d = dict(row) + # Fix Plex/Jellyfin relative thumb URL + if d.get('thumb_url'): + d['thumb_url'] = fix_artist_image_url(d['thumb_url']) or d['thumb_url'] + if d.get('genres'): + try: + d['genres'] = json.loads(d['genres']) + except (json.JSONDecodeError, TypeError): + pass + snapshot = d + + except Exception as e: + logger.error(f"Error building issue snapshot: {e}") + snapshot['_snapshot_error'] = str(e) + + return snapshot + + def _sync_tracks_to_server(track_rows, server_type): """Sync metadata for tracks to the active media server after writing file tags. @@ -19762,9 +20090,14 @@ def _run_full_missing_tracks_process(batch_id, playlist_id, tracks_json): batch_playlist_name = batch.get('playlist_name', 'Unknown Playlist') # === ALBUM PRE-FLIGHT: Search for complete album folder before track-by-track === + # Only run pre-flight when Soulseek is the download source (or hybrid with soulseek) preflight_source = None preflight_tracks = None - if batch_is_album and batch_album_context and batch_artist_context: + dl_source_mode = config_manager.get('download_source.mode', 'soulseek') + soulseek_is_source = dl_source_mode == 'soulseek' or ( + dl_source_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary', 'soulseek') == 'soulseek' + ) + if batch_is_album and batch_album_context and batch_artist_context and soulseek_is_source: artist_name = batch_artist_context.get('name', '') album_name = batch_album_context.get('name', '') if artist_name and album_name: diff --git a/webui/index.html b/webui/index.html index da7d6448..eb6d5bf7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -156,6 +156,11 @@ Settings + + +
+ +
+ + + + + + +
diff --git a/webui/static/script.js b/webui/static/script.js index e66f2a43..2ace0a99 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -781,7 +781,7 @@ function getProfileHomePage() { function isPageAllowed(pageId) { if (!currentProfile) return true; if (currentProfile.id === 1) return true; - if (pageId === 'help') return true; + if (pageId === 'help' || pageId === 'issues') return true; if (pageId === 'artist-detail') { // artist-detail requires library access const ap = currentProfile.allowed_pages; @@ -1077,7 +1077,7 @@ function updateProfileIndicator() { if (page === 'settings') { // Settings always gated by is_admin btn.style.display = currentProfile.is_admin ? '' : 'none'; - } else if (page === 'help') { + } else if (page === 'help' || page === 'issues') { btn.style.display = ''; // Always visible } else if (currentProfile.id === 1) { btn.style.display = ''; // Root admin sees all @@ -1674,6 +1674,9 @@ function initApp() { // Start always-on download polling (batched, minimal overhead) startGlobalDownloadPolling(); + // Load issues badge count + loadIssuesBadge(); + // Load initial data loadInitialData(); @@ -1950,6 +1953,9 @@ async function loadPageData(pageId) { case 'automations': await loadAutomations(); break; + case 'issues': + await loadIssuesPage(); + break; case 'help': initializeDocsPage(); break; @@ -11545,7 +11551,7 @@ async function startMissingTracksProcess(playlistId) { // If this is an artist album download, use album name and include full context // Match 'artist_album_', 'enhanced_search_album_', 'enhanced_search_track_', 'discover_album_', and 'seasonal_album_' prefixes - if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_')) { + if (playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('issue_download_')) { requestBody.playlist_name = process.album?.name || process.playlist.name; requestBody.is_album_download = true; requestBody.album_context = process.album; // Full Spotify album object @@ -34127,15 +34133,11 @@ function navigateToArtistDetail(artistId, artistName) { artistDetailPageState.enhancedTrackSort = {}; artistDetailPageState.enhancedView = false; - // Reset enhanced view toggle to standard (hide for non-admin) + // Reset enhanced view toggle to standard const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); - const isAdminUser = currentProfile && currentProfile.is_admin; toggleBtns.forEach(btn => { btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard'); }); - // Hide the View toggle filter group entirely for non-admin - const viewFilterGroup = toggleBtns[0] && toggleBtns[0].closest('.filter-group'); - if (viewFilterGroup) viewFilterGroup.style.display = isAdminUser ? '' : 'none'; const enhancedContainer = document.getElementById('enhanced-view-container'); if (enhancedContainer) enhancedContainer.classList.add('hidden'); const standardSections = document.querySelector('.discography-sections'); @@ -35260,9 +35262,11 @@ function applyDiscographyFilters() { // ==================== Enhanced Library Management View ==================== +function isEnhancedAdmin() { + return currentProfile && currentProfile.is_admin; +} + function toggleEnhancedView(enabled) { - // Enhanced view is admin-only (management tool with inline editing) - if (enabled && currentProfile && !currentProfile.is_admin) return; const standardSections = document.querySelector('.discography-sections'); const enhancedContainer = document.getElementById('enhanced-view-container'); @@ -35475,62 +35479,64 @@ function renderArtistMetaPanel(artist) { headerLeft.appendChild(headerInfo); header.appendChild(headerLeft); - // Right side: edit toggle + // Right side: admin actions const headerRight = document.createElement('div'); headerRight.className = 'enhanced-artist-meta-actions'; - const editToggle = document.createElement('button'); - editToggle.className = 'enhanced-meta-edit-toggle'; - editToggle.textContent = 'Edit Metadata'; - editToggle.onclick = () => { - const form = document.getElementById('enhanced-artist-meta-form'); - if (form) { - const isVisible = !form.classList.contains('hidden'); - form.classList.toggle('hidden'); - editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor'; - editToggle.classList.toggle('active', !isVisible); - } - }; - headerRight.appendChild(editToggle); - - // Enrich dropdown button - const enrichWrap = document.createElement('div'); - enrichWrap.className = 'enhanced-enrich-wrap'; - const enrichBtn = document.createElement('button'); - enrichBtn.className = 'enhanced-enrich-btn'; - enrichBtn.textContent = 'Enrich ▾'; - enrichBtn.onclick = (e) => { - e.stopPropagation(); - enrichMenu.classList.toggle('visible'); - }; - enrichWrap.appendChild(enrichBtn); - - const enrichMenu = document.createElement('div'); - enrichMenu.className = 'enhanced-enrich-menu'; - const services = [ - - { id: 'spotify', label: 'Spotify', icon: '🟢' }, - { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, - { id: 'deezer', label: 'Deezer', icon: '🟣' }, - { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, - { id: 'itunes', label: 'iTunes', icon: '🔴' }, - { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, - { id: 'genius', label: 'Genius', icon: '🟡' }, - { id: 'tidal', label: 'Tidal', icon: '⬛' }, - { id: 'qobuz', label: 'Qobuz', icon: '🔷' }, - ]; - services.forEach(svc => { - const item = document.createElement('div'); - item.className = 'enhanced-enrich-menu-item'; - item.textContent = `${svc.icon} ${svc.label}`; - item.onclick = (e) => { + + if (isEnhancedAdmin()) { + const editToggle = document.createElement('button'); + editToggle.className = 'enhanced-meta-edit-toggle'; + editToggle.textContent = 'Edit Metadata'; + editToggle.onclick = () => { + const form = document.getElementById('enhanced-artist-meta-form'); + if (form) { + const isVisible = !form.classList.contains('hidden'); + form.classList.toggle('hidden'); + editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor'; + editToggle.classList.toggle('active', !isVisible); + } + }; + headerRight.appendChild(editToggle); + + // Enrich dropdown button + const enrichWrap = document.createElement('div'); + enrichWrap.className = 'enhanced-enrich-wrap'; + const enrichBtn = document.createElement('button'); + enrichBtn.className = 'enhanced-enrich-btn'; + enrichBtn.textContent = 'Enrich ▾'; + enrichBtn.onclick = (e) => { e.stopPropagation(); - enrichMenu.classList.remove('visible'); - runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id); + enrichMenu.classList.toggle('visible'); }; - enrichMenu.appendChild(item); - }); - enrichWrap.appendChild(enrichMenu); - headerRight.appendChild(enrichWrap); + enrichWrap.appendChild(enrichBtn); + + const enrichMenu = document.createElement('div'); + enrichMenu.className = 'enhanced-enrich-menu'; + const services = [ + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, + { id: 'genius', label: 'Genius', icon: '🟡' }, + { id: 'tidal', label: 'Tidal', icon: '⬛' }, + { id: 'qobuz', label: 'Qobuz', icon: '🔷' }, + ]; + services.forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + enrichMenu.classList.remove('visible'); + runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id); + }; + enrichMenu.appendChild(item); + }); + enrichWrap.appendChild(enrichMenu); + headerRight.appendChild(enrichWrap); + } header.appendChild(headerRight); @@ -35923,63 +35929,74 @@ function renderExpandedAlbumHeader(album) { }); info.appendChild(statusRow); - // Enrich button for album + // Action buttons row const enrichRow = document.createElement('div'); enrichRow.className = 'enhanced-expanded-actions'; - const albumEnrichWrap = document.createElement('div'); - albumEnrichWrap.className = 'enhanced-enrich-wrap'; - const albumEnrichBtn = document.createElement('button'); - albumEnrichBtn.className = 'enhanced-enrich-btn small'; - albumEnrichBtn.textContent = 'Enrich Album ▾'; - albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); }; - albumEnrichWrap.appendChild(albumEnrichBtn); - const albumEnrichMenu = document.createElement('div'); - albumEnrichMenu.className = 'enhanced-enrich-menu'; - [ - - { id: 'spotify', label: 'Spotify', icon: '🟢' }, - { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, - { id: 'deezer', label: 'Deezer', icon: '🟣' }, - { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, - { id: 'itunes', label: 'iTunes', icon: '🔴' }, - { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, - ].forEach(svc => { - const item = document.createElement('div'); - item.className = 'enhanced-enrich-menu-item'; - item.textContent = `${svc.icon} ${svc.label}`; - item.onclick = (e) => { - e.stopPropagation(); - albumEnrichMenu.classList.remove('visible'); - const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; - runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId); - }; - albumEnrichMenu.appendChild(item); - }); - albumEnrichWrap.appendChild(albumEnrichMenu); - enrichRow.appendChild(albumEnrichWrap); - - // Write Tags button for entire album - const writeTagsBtn = document.createElement('button'); - writeTagsBtn.className = 'enhanced-write-tags-album-btn'; - writeTagsBtn.innerHTML = '✎ Write All Tags'; - writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album'; - writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); }; - enrichRow.appendChild(writeTagsBtn); - - // Reorganize button - const reorganizeBtn = document.createElement('button'); - reorganizeBtn.className = 'enhanced-reorganize-album-btn'; - reorganizeBtn.innerHTML = '📁 Reorganize'; - reorganizeBtn.title = 'Reorganize album files using a custom path template'; - reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; - enrichRow.appendChild(reorganizeBtn); - - // Delete album button - const deleteAlbumBtn = document.createElement('button'); - deleteAlbumBtn.className = 'enhanced-delete-album-btn'; - deleteAlbumBtn.textContent = 'Delete Album'; - deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); }; - enrichRow.appendChild(deleteAlbumBtn); + + if (isEnhancedAdmin()) { + const albumEnrichWrap = document.createElement('div'); + albumEnrichWrap.className = 'enhanced-enrich-wrap'; + const albumEnrichBtn = document.createElement('button'); + albumEnrichBtn.className = 'enhanced-enrich-btn small'; + albumEnrichBtn.textContent = 'Enrich Album ▾'; + albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); }; + albumEnrichWrap.appendChild(albumEnrichBtn); + const albumEnrichMenu = document.createElement('div'); + albumEnrichMenu.className = 'enhanced-enrich-menu'; + [ + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, + ].forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + albumEnrichMenu.classList.remove('visible'); + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; + runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId); + }; + albumEnrichMenu.appendChild(item); + }); + albumEnrichWrap.appendChild(albumEnrichMenu); + enrichRow.appendChild(albumEnrichWrap); + + const writeTagsBtn = document.createElement('button'); + writeTagsBtn.className = 'enhanced-write-tags-album-btn'; + writeTagsBtn.innerHTML = '✎ Write All Tags'; + writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album'; + writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); }; + enrichRow.appendChild(writeTagsBtn); + + const reorganizeBtn = document.createElement('button'); + reorganizeBtn.className = 'enhanced-reorganize-album-btn'; + reorganizeBtn.innerHTML = '📁 Reorganize'; + reorganizeBtn.title = 'Reorganize album files using a custom path template'; + reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; + enrichRow.appendChild(reorganizeBtn); + + const deleteAlbumBtn = document.createElement('button'); + deleteAlbumBtn.className = 'enhanced-delete-album-btn'; + deleteAlbumBtn.textContent = 'Delete Album'; + deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); }; + enrichRow.appendChild(deleteAlbumBtn); + } + + // Report Issue button (available to all users) + const reportBtn = document.createElement('button'); + reportBtn.className = 'enhanced-report-issue-btn'; + reportBtn.innerHTML = '⚑ Report Issue'; + reportBtn.title = 'Report a problem with this album'; + reportBtn.onclick = (e) => { + e.stopPropagation(); + const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + showReportIssueModal('album', album.id, album.title || '', aName); + }; + enrichRow.appendChild(reportBtn); info.appendChild(enrichRow); @@ -36003,6 +36020,7 @@ function renderAlbumMetaRow(album) { { key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' }, ]; + const admin = isEnhancedAdmin(); fields.forEach(f => { const fieldDiv = document.createElement('div'); fieldDiv.className = 'enhanced-album-meta-field'; @@ -36010,30 +36028,38 @@ function renderAlbumMetaRow(album) { label.className = 'enhanced-album-meta-label'; label.textContent = f.label; fieldDiv.appendChild(label); - const input = document.createElement('input'); - input.className = 'enhanced-album-meta-input'; - input.type = f.type || 'text'; - input.dataset.albumId = album.id; - input.dataset.field = f.key; - input.value = String(f.value); - input.addEventListener('click', e => e.stopPropagation()); - fieldDiv.appendChild(input); + if (admin) { + const input = document.createElement('input'); + input.className = 'enhanced-album-meta-input'; + input.type = f.type || 'text'; + input.dataset.albumId = album.id; + input.dataset.field = f.key; + input.value = String(f.value); + input.addEventListener('click', e => e.stopPropagation()); + fieldDiv.appendChild(input); + } else { + const span = document.createElement('span'); + span.className = 'enhanced-album-meta-value'; + span.textContent = String(f.value) || '—'; + fieldDiv.appendChild(span); + } row.appendChild(fieldDiv); }); - // Save button - const saveDiv = document.createElement('div'); - saveDiv.className = 'enhanced-album-meta-field'; - const spacer = document.createElement('label'); - spacer.className = 'enhanced-album-meta-label'; - spacer.innerHTML = ' '; - saveDiv.appendChild(spacer); - const saveBtn = document.createElement('button'); - saveBtn.className = 'enhanced-album-save-btn'; - saveBtn.textContent = 'Save Album'; - saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); }; - saveDiv.appendChild(saveBtn); - row.appendChild(saveDiv); + if (admin) { + const saveDiv = document.createElement('div'); + saveDiv.className = 'enhanced-album-meta-field'; + const spacer = document.createElement('label'); + spacer.className = 'enhanced-album-meta-label'; + spacer.innerHTML = ' '; + saveDiv.appendChild(spacer); + const saveBtn = document.createElement('button'); + saveBtn.className = 'enhanced-album-save-btn'; + saveBtn.textContent = 'Save Album'; + saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); }; + saveDiv.appendChild(saveBtn); + row.appendChild(saveDiv); + } return row; } @@ -36056,16 +36082,24 @@ function renderTrackTable(album) { const table = document.createElement('table'); table.className = 'enhanced-track-table'; + const admin = isEnhancedAdmin(); + // Clear stale selections for non-admin to prevent ghost state + if (!admin) { + artistDetailPageState.selectedTracks.clear(); + } + // Header const thead = document.createElement('thead'); const headRow = document.createElement('tr'); - const selectAllTh = document.createElement('th'); - const selectAllCb = document.createElement('input'); - selectAllCb.type = 'checkbox'; - selectAllCb.className = 'enhanced-track-checkbox'; - selectAllCb.onchange = function() { toggleSelectAllTracks(album.id, this.checked); }; - selectAllTh.appendChild(selectAllCb); - headRow.appendChild(selectAllTh); + if (admin) { + const selectAllTh = document.createElement('th'); + const selectAllCb = document.createElement('input'); + selectAllCb.type = 'checkbox'; + selectAllCb.className = 'enhanced-track-checkbox'; + selectAllCb.onchange = function() { toggleSelectAllTracks(album.id, this.checked); }; + selectAllTh.appendChild(selectAllCb); + headRow.appendChild(selectAllTh); + } const columns = [ { label: '', cls: 'col-play' }, @@ -36079,8 +36113,12 @@ function renderTrackTable(album) { { label: 'File', cls: 'col-path' }, { label: 'Match', cls: 'col-match' }, { label: '', cls: 'col-queue' }, - { label: '', cls: 'col-writetag' }, - { label: '', cls: 'col-delete' }, + ...(admin ? [ + { label: '', cls: 'col-writetag' }, + { label: '', cls: 'col-delete' }, + ] : [ + { label: '', cls: 'col-report' }, + ]), ]; columns.forEach(col => { const th = document.createElement('th'); @@ -36125,15 +36163,17 @@ function renderTrackTable(album) { tr.dataset.trackId = track.id; if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected'); - // Checkbox - const cbTd = document.createElement('td'); - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.className = 'enhanced-track-checkbox'; - cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); - cb.onchange = () => toggleTrackSelection(String(track.id)); - cbTd.appendChild(cb); - tr.appendChild(cbTd); + // Checkbox (admin only) + if (admin) { + const cbTd = document.createElement('td'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'enhanced-track-checkbox'; + cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); + cb.onchange = () => toggleTrackSelection(String(track.id)); + cbTd.appendChild(cb); + tr.appendChild(cbTd); + } // Play button const playTd = document.createElement('td'); @@ -36155,11 +36195,13 @@ function renderTrackTable(album) { playTd.appendChild(playBtn); tr.appendChild(playTd); - // Track number (editable) + // Track number (editable for admin) const numTd = document.createElement('td'); - numTd.className = 'col-num editable'; + numTd.className = 'col-num' + (admin ? ' editable' : ''); numTd.textContent = track.track_number || '-'; - numTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(numTd, 'track', track.id, 'track_number', track.track_number || ''); }; + if (admin) { + numTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(numTd, 'track', track.id, 'track_number', track.track_number || ''); }; + } tr.appendChild(numTd); // Disc number @@ -36168,11 +36210,13 @@ function renderTrackTable(album) { discTd.textContent = track.disc_number || '-'; tr.appendChild(discTd); - // Title (editable) + // Title (editable for admin) const titleTd = document.createElement('td'); - titleTd.className = 'col-title editable'; + titleTd.className = 'col-title' + (admin ? ' editable' : ''); titleTd.textContent = track.title || 'Unknown'; - titleTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(titleTd, 'track', track.id, 'title', track.title || ''); }; + if (admin) { + titleTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(titleTd, 'track', track.id, 'title', track.title || ''); }; + } tr.appendChild(titleTd); // Duration @@ -36202,11 +36246,13 @@ function renderTrackTable(album) { brTd.appendChild(brSpan); tr.appendChild(brTd); - // BPM (editable) + // BPM (editable for admin) const bpmTd = document.createElement('td'); - bpmTd.className = 'col-bpm editable'; + bpmTd.className = 'col-bpm' + (admin ? ' editable' : ''); bpmTd.textContent = track.bpm || '-'; - bpmTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(bpmTd, 'track', track.id, 'bpm', track.bpm || ''); }; + if (admin) { + bpmTd.onclick = (e) => { e.stopPropagation(); startInlineEdit(bpmTd, 'track', track.id, 'bpm', track.bpm || ''); }; + } tr.appendChild(bpmTd); // File path (last column) @@ -36239,10 +36285,12 @@ function renderTrackTable(album) { chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found'); chip.textContent = s.label; chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`; - chip.onclick = (e) => { - e.stopPropagation(); - openManualMatchModal('track', track.id, s.svc, track.title || '', aId); - }; + if (admin) { + chip.onclick = (e) => { + e.stopPropagation(); + openManualMatchModal('track', track.id, s.svc, track.title || '', aId); + }; + } matchCell.appendChild(chip); }); matchTd.appendChild(matchCell); @@ -36282,29 +36330,46 @@ function renderTrackTable(album) { } tr.appendChild(queueTd); - // Write Tags button - const tagTd = document.createElement('td'); - tagTd.className = 'col-writetag'; - if (track.file_path) { - const tagBtn = document.createElement('button'); - tagBtn.className = 'enhanced-write-tag-btn'; - tagBtn.innerHTML = '✎'; - tagBtn.title = 'Write tags to file'; - tagBtn.onclick = (e) => { e.stopPropagation(); showTagPreview(track.id); }; - tagTd.appendChild(tagBtn); - } - tr.appendChild(tagTd); - - // Delete button - const delTd = document.createElement('td'); - delTd.className = 'col-delete'; - const delBtn = document.createElement('button'); - delBtn.className = 'enhanced-delete-btn'; - delBtn.innerHTML = '✕'; - delBtn.title = 'Delete track from library'; - delBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryTrack(track.id, album.id); }; - delTd.appendChild(delBtn); - tr.appendChild(delTd); + if (admin) { + // Write Tags button (admin only) + const tagTd = document.createElement('td'); + tagTd.className = 'col-writetag'; + if (track.file_path) { + const tagBtn = document.createElement('button'); + tagBtn.className = 'enhanced-write-tag-btn'; + tagBtn.innerHTML = '✎'; + tagBtn.title = 'Write tags to file'; + tagBtn.onclick = (e) => { e.stopPropagation(); showTagPreview(track.id); }; + tagTd.appendChild(tagBtn); + } + tr.appendChild(tagTd); + + // Delete button (admin only) + const delTd = document.createElement('td'); + delTd.className = 'col-delete'; + const delBtn = document.createElement('button'); + delBtn.className = 'enhanced-delete-btn'; + delBtn.innerHTML = '✕'; + delBtn.title = 'Delete track from library'; + delBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryTrack(track.id, album.id); }; + delTd.appendChild(delBtn); + tr.appendChild(delTd); + } else { + // Report Issue button per track (non-admin) + const reportTd = document.createElement('td'); + reportTd.className = 'col-report'; + const reportBtn = document.createElement('button'); + reportBtn.className = 'enhanced-track-report-btn'; + reportBtn.innerHTML = '⚑'; + reportBtn.title = 'Report issue with this track'; + reportBtn.onclick = (e) => { + e.stopPropagation(); + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + showReportIssueModal('track', track.id, track.title || 'Unknown', artistName, album.title || ''); + }; + reportTd.appendChild(reportBtn); + tr.appendChild(reportTd); + } tbody.appendChild(tr); }); @@ -36616,6 +36681,10 @@ function updateBulkBar() { const bar = document.getElementById('enhanced-bulk-bar'); const count = document.getElementById('enhanced-bulk-count'); if (!bar || !count) return; + if (!isEnhancedAdmin()) { + bar.classList.remove('visible'); + return; + } const n = artistDetailPageState.selectedTracks.size; count.textContent = n; bar.classList.toggle('visible', n > 0); @@ -51739,6 +51808,933 @@ function _autoInsertVar(textareaId, variable) { el.focus(); } +// ===== ISSUES PAGE ===== + +const ISSUE_CATEGORIES = { + wrong_track: { label: 'Wrong Track', icon: '❌', description: 'This file plays a completely different song than expected', applies: ['track'] }, + wrong_metadata: { label: 'Wrong Metadata', icon: '✎', description: 'Title, artist, year, or other tags are incorrect', applies: ['track', 'album'] }, + wrong_cover: { label: 'Wrong Cover Art', icon: '📷', description: 'The album artwork is wrong or missing', applies: ['album'] }, + wrong_artist: { label: 'Wrong Artist', icon: '👤', description: 'This track is filed under the wrong artist', applies: ['track'] }, + duplicate_tracks: { label: 'Duplicate Tracks', icon: '🔁', description: 'The same track appears more than once in this album', applies: ['album'] }, + missing_tracks: { label: 'Missing Tracks', icon: '❓', description: 'Tracks that should be here are missing from this album', applies: ['album'] }, + audio_quality: { label: 'Audio Quality', icon: '🎵', description: 'Audio has quality issues — clipping, low bitrate, silence, etc.', applies: ['track'] }, + wrong_album: { label: 'Wrong Album', icon: '💿', description: 'This track belongs to a different album', applies: ['track'] }, + incomplete_album: { label: 'Incomplete Album', icon: '⚠', description: 'Album is partially downloaded — some tracks present, others not', applies: ['album'] }, + other: { label: 'Other', icon: '💬', description: 'Any other issue not listed above', applies: ['track', 'album'] }, +}; + +const ISSUE_STATUS_META = { + open: { label: 'Open', cls: 'issue-status-open' }, + in_progress: { label: 'In Progress', cls: 'issue-status-progress' }, + resolved: { label: 'Resolved', cls: 'issue-status-resolved' }, + dismissed: { label: 'Dismissed', cls: 'issue-status-dismissed' }, +}; + +let _issuesPageState = { loaded: false }; + +function _issueHeaders(extra) { + const h = { 'X-Profile-Id': String(currentProfile ? currentProfile.id : 1) }; + if (extra) Object.assign(h, extra); + return h; +} + +async function loadIssuesPage() { + const admin = isEnhancedAdmin(); + const subtitle = document.getElementById('issues-subtitle'); + if (subtitle) { + subtitle.textContent = admin ? 'Manage and resolve reported library problems' : 'Track and resolve library problems'; + } + await Promise.all([loadIssuesList(), loadIssuesCounts()]); +} + +async function loadIssuesCounts() { + try { + const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success) return; + const counts = data.counts; + const statsEl = document.getElementById('issues-stats'); + if (!statsEl) return; + const total = (counts.open || 0) + (counts.in_progress || 0) + (counts.resolved || 0) + (counts.dismissed || 0); + statsEl.innerHTML = ` +
+
${counts.open || 0}
+
Open
+
+
+
${counts.in_progress || 0}
+
In Progress
+
+
+
${counts.resolved || 0}
+
Resolved
+
+
+
${counts.dismissed || 0}
+
Dismissed
+
+
+
${total}
+
Total
+
+ `; + // Update nav badge + const badge = document.getElementById('issues-nav-badge'); + if (badge) { + const openCount = counts.open || 0; + badge.textContent = openCount; + badge.classList.toggle('hidden', openCount === 0); + } + } catch (e) { + console.error('Failed to load issue counts:', e); + } +} + +async function loadIssuesList() { + const listEl = document.getElementById('issues-list'); + if (!listEl) return; + listEl.innerHTML = '
Loading issues...
'; + + const statusFilter = document.getElementById('issues-filter-status')?.value || ''; + const categoryFilter = document.getElementById('issues-filter-category')?.value || ''; + + let url = '/api/issues?'; + if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; + if (categoryFilter) url += `category=${encodeURIComponent(categoryFilter)}&`; + + try { + const profileId = currentProfile ? currentProfile.id : 1; + const resp = await fetch(url, { headers: { 'X-Profile-Id': String(profileId) } }); + const data = await resp.json(); + if (!data.success || !data.issues || data.issues.length === 0) { + listEl.innerHTML = ` +
+
🔍
+
No issues found
+
${statusFilter || categoryFilter ? 'Try adjusting your filters' : 'No issues have been reported yet'}
+
+ `; + return; + } + listEl.innerHTML = ''; + data.issues.forEach(issue => { + listEl.appendChild(renderIssueCard(issue)); + }); + } catch (e) { + console.error('Failed to load issues:', e); + listEl.innerHTML = '
Failed to load issues
'; + } +} + +function renderIssueCard(issue) { + const card = document.createElement('div'); + card.className = 'issue-card'; + card.dataset.issueId = issue.id; + card.onclick = () => showIssueDetailModal(issue.id); + + const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; + const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; + const admin = isEnhancedAdmin(); + + let snapshot = {}; + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {} + + const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); + const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; + const artistName = snapshot.artist_name || ''; + const albumName = snapshot.album_title || ''; + const thumbUrl = snapshot.thumb_url || snapshot.album_thumb || ''; + + const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; + const createdTime = issue.created_at ? new Date(issue.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''; + + // Priority indicator + const priorityCls = issue.priority === 'high' ? 'issue-priority-high' : (issue.priority === 'low' ? 'issue-priority-low' : 'issue-priority-normal'); + + let thumbHtml = ''; + if (thumbUrl) { + thumbHtml = ``; + } else { + thumbHtml = `
${catMeta.icon}
`; + } + + let metaLine = ''; + if (issue.entity_type === 'track') { + metaLine = [artistName, albumName].filter(Boolean).map(s => _esc(s)).join(' — '); + } else if (issue.entity_type === 'album') { + metaLine = artistName ? _esc(artistName) : ''; + } + + let profileBadge = ''; + if (admin && issue.reporter_name) { + profileBadge = `by ${_esc(issue.reporter_name)}`; + } + + let adminResponseIndicator = ''; + if (issue.admin_response) { + adminResponseIndicator = '💬'; + } + + card.innerHTML = ` +
+ ${thumbHtml} +
+
+
+ ${catMeta.icon} + ${_esc(issue.title)} + ${adminResponseIndicator} +
+
+ ${_esc(entityLabel)} + ${_esc(entityName)} + ${metaLine ? `${metaLine}` : ''} +
+ ${issue.description ? `
${_esc(issue.description)}
` : ''} + +
+
+ ${_esc(statusMeta.label)} + +
+ `; + return card; +} + +// --- Report Issue Modal --- + +let _reportIssueState = {}; + +function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) { + _reportIssueState = { entityType, entityId, entityName, artistName, albumTitle: albumTitle || '' }; + const overlay = document.getElementById('report-issue-overlay'); + const titleEl = document.getElementById('report-issue-title'); + const body = document.getElementById('report-issue-body'); + if (!overlay || !body) return; + + const entityLabel = entityType === 'track' ? 'Track' : (entityType === 'album' ? 'Album' : 'Artist'); + titleEl.textContent = `Report Issue — ${entityLabel}`; + + body.innerHTML = ` +
+
${_esc(entityName)}
+ ${artistName ? `
${_esc(artistName)}${albumTitle ? ' — ' + _esc(albumTitle) : ''}
` : ''} +
+
+ +
+ ${Object.entries(ISSUE_CATEGORIES) + .filter(([, cat]) => !cat.applies || cat.applies.includes(entityType)) + .map(([key, cat]) => ` +
+
${cat.icon}
+
${_esc(cat.label)}
+
${_esc(cat.description)}
+
+ `).join('')} +
+
+ + `; + + _reportIssueState.selectedCategory = null; + _reportIssueState.selectedPriority = 'normal'; + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) submitBtn.disabled = true; + + overlay.classList.remove('hidden'); +} + +function selectIssueCategory(el, category) { + document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected')); + el.classList.add('selected'); + _reportIssueState.selectedCategory = category; + + const detailsSection = document.getElementById('report-issue-details-section'); + if (detailsSection) detailsSection.style.display = ''; + + // Auto-generate title based on category + const titleInput = document.getElementById('report-issue-input-title'); + const catMeta = ISSUE_CATEGORIES[category]; + if (titleInput && !titleInput._userEdited) { + const entityName = _reportIssueState.entityName || ''; + titleInput.value = `${catMeta.label}: ${entityName}`; + } + + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) submitBtn.disabled = false; +} + +function selectIssuePriority(el, priority) { + document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected')); + el.classList.add('selected'); + _reportIssueState.selectedPriority = priority; +} + +function closeReportIssueModal() { + const overlay = document.getElementById('report-issue-overlay'); + if (overlay) overlay.classList.add('hidden'); + _reportIssueState = {}; +} + +async function submitIssue() { + if (_reportIssueState._submitting) return; + const category = _reportIssueState.selectedCategory; + if (!category) { + showToast('Please select an issue category', 'error'); + return; + } + + const titleInput = document.getElementById('report-issue-input-title'); + const descInput = document.getElementById('report-issue-input-desc'); + const title = (titleInput?.value || '').trim(); + const description = (descInput?.value || '').trim(); + + if (!title) { + showToast('Please provide a title for the issue', 'error'); + return; + } + + _reportIssueState._submitting = true; + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Submitting...'; + } + + try { + const resp = await fetch('/api/issues', { + method: 'POST', + headers: _issueHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + profile_id: currentProfile ? currentProfile.id : 1, + entity_type: _reportIssueState.entityType, + entity_id: String(_reportIssueState.entityId), + category: category, + title: title, + description: description, + priority: _reportIssueState.selectedPriority || 'normal', + }), + }); + const data = await resp.json(); + if (data.success) { + showToast('Issue reported successfully', 'success'); + closeReportIssueModal(); + // Refresh issues page if visible + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + // Update badge + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to submit issue', 'error'); + } + } catch (e) { + console.error('Failed to submit issue:', e); + showToast('Failed to submit issue', 'error'); + } finally { + _reportIssueState._submitting = false; + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Submit Issue'; + } + } +} + +// --- Issue Detail Modal --- + +async function showIssueDetailModal(issueId) { + const overlay = document.getElementById('issue-detail-overlay'); + const body = document.getElementById('issue-detail-body'); + const footer = document.getElementById('issue-detail-footer'); + const titleEl = document.getElementById('issue-detail-title'); + if (!overlay || !body) return; + + body.innerHTML = '
Loading...
'; + footer.innerHTML = ''; + overlay.classList.remove('hidden'); + + try { + const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success || !data.issue) { + body.innerHTML = '
Issue not found
'; + return; + } + renderIssueDetail(data.issue, body, footer, titleEl); + } catch (e) { + console.error('Failed to load issue:', e); + body.innerHTML = '
Failed to load issue
'; + } +} + +function renderIssueDetail(issue, body, footer, titleEl) { + const admin = isEnhancedAdmin(); + const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; + const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; + + let snapshot = {}; + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {} + + const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); + const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; + const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || ''; + const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); + const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id; + + // Resolve image URLs — album art and artist photo + let artistThumb = ''; + let albumThumb = ''; + if (issue.entity_type === 'album') { + albumThumb = snapshot.thumb_url || ''; + artistThumb = snapshot.artist_thumb || ''; + } else if (issue.entity_type === 'track') { + albumThumb = snapshot.album_thumb || ''; + artistThumb = snapshot.artist_thumb || ''; + } else { + // Artist issue + artistThumb = snapshot.thumb_url || ''; + } + + // Determine the album-level Spotify ID for download/wishlist actions + const spotifyAlbumId = snapshot.spotify_album_id || ''; + + console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) }); + + const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown'; + const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : ''; + + titleEl.textContent = `Issue #${issue.id}`; + + // --- Build external links chips --- + function _extLinks(snap) { + const links = []; + if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' }); + if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' }); + if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' }); + if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' }); + if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' }); + if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' }); + if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' }); + if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' }); + if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' }); + if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' }); + if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' }); + if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id }); + if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id }); + return links; + } + + const extLinks = _extLinks(snapshot); + let extLinksHtml = ''; + if (extLinks.length > 0) { + const chips = extLinks.map(l => { + if (l.url) { + return `${_esc(l.svc)} ${_esc(l.type)}`; + } + return `${_esc(l.svc)} ${_esc(l.type)}`; + }).join(''); + extLinksHtml = `
${chips}
`; + } + + // --- Build enhanced-library-style album/track widget --- + // Determine which album data to show (for album issues it's the entity, for track issues it's the parent) + const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track'); + const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); + const albumYear = snapshot.year || ''; + const albumLabel = snapshot.label || ''; + const albumType = snapshot.record_type || ''; + const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || ''); + const albumGenres = snapshot.genres || []; + + // --- Build the hero section (artist photo + album art + info) --- + let heroHtml = ''; + if (showAlbumWidget) { + // Genre tags + let genreTagsHtml = ''; + if (Array.isArray(albumGenres) && albumGenres.length > 0) { + genreTagsHtml = `
${albumGenres.slice(0, 5).map(g => `${_esc(g)}`).join('')}
`; + } + + // Album meta line + const albumMetaParts = []; + if (albumYear) albumMetaParts.push(String(albumYear)); + if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1)); + if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks'); + if (albumLabel) albumMetaParts.push(albumLabel); + + // For track issues, show the track title under the album + const trackNameLine = issue.entity_type === 'track' && entityName + ? `
♫ ${_esc(entityName)}
` : ''; + + heroHtml = ` +
+
+ ${artistThumb ? `` : ''} + ${albumThumb ? `` : ''} +
${catMeta.icon}
+
+
+ ${artistName ? `
${_esc(artistName)}
` : ''} +
${_esc(albumName)}
+ ${trackNameLine} + ${albumMetaParts.length > 0 ? `
${_esc(albumMetaParts.join(' \u00B7 '))}
` : ''} + ${genreTagsHtml} + ${extLinksHtml} +
+
+ `; + } else { + // Artist-level issue — simpler hero + heroHtml = ` +
+
+ ${artistThumb ? `` : `
${catMeta.icon}
`} +
+
+
${_esc(entityName)}
+ ${extLinksHtml} +
+
+ `; + } + + // --- Issue info bar --- + let issueInfoHtml = ` +
+
+ ${_esc(statusMeta.label)} + + ${catMeta.icon} ${_esc(catMeta.label)} +
+
+ Reported ${_esc(createdDate)} + ${issue.reporter_name && admin ? `by ${_esc(issue.reporter_name)}` : ''} + ${resolvedDate ? `Resolved ${_esc(resolvedDate)}` : ''} +
+
+ `; + + // --- Issue description --- + let descriptionHtml = ` +
+
Issue
+
${_esc(issue.title)}
+ ${issue.description ? `
${_esc(issue.description)}
` : '
No additional details provided
'} +
+ `; + + // --- Action buttons (Download Album / Add to Wishlist) for admin --- + let actionButtonsHtml = ''; + if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) { + actionButtonsHtml = ` +
+ + +
+ `; + } + + // --- Metadata grid for track-level issues --- + let metaGridHtml = ''; + if (issue.entity_type === 'track') { + const metaItems = []; + if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) }); + if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) }); + if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format }); + if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' }); + if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) }); + if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality }); + if (metaItems.length > 0) { + metaGridHtml = ` +
+
Track Details
+
+ ${metaItems.map(m => ` +
+ ${m.icon} + ${_esc(m.label)} + ${_esc(m.value)} +
+ `).join('')} +
+
+ `; + } + } + + // --- File path display for tracks --- + let filePathHtml = ''; + if (snapshot.file_path) { + filePathHtml = ` +
+
File Path
+
${_esc(snapshot.file_path)}
+
+ `; + } + + // --- Enhanced-library-style track listing --- + let trackListHtml = ''; + if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) { + let lastDisc = null; + let rows = ''; + const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1); + snapshot.tracks.forEach(t => { + const disc = t.disc_number || 1; + if (hasMultiDisc && disc !== lastDisc) { + rows += `
Disc ${disc}
`; + lastDisc = disc; + } + const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : ''); + const fmtLower = fmt.toLowerCase(); + const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other'); + const br = t.bitrate ? parseInt(t.bitrate) : 0; + const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low'); + const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : ''; + + rows += ` +
+ ${_esc(String(t.track_number || '-'))} + ${_esc(t.title || 'Unknown')} + ${durStr ? `${durStr}` : ''} + + ${fmt ? `${_esc(fmt)}` : ''} + ${br ? `${br}k` : ''} + +
+ `; + }); + trackListHtml = ` +
+
Track Listing ${snapshot.tracks.length} tracks
+
${rows}
+
+ `; + } + + // --- Admin response section --- + let adminResponseHtml = ''; + if (admin) { + adminResponseHtml = ` +
+
Admin Response
+ +
+ `; + } else if (issue.admin_response) { + adminResponseHtml = ` +
+
Admin Response
+
${_esc(issue.admin_response)}
+
+ `; + } + + body.innerHTML = ` + ${heroHtml} + ${issueInfoHtml} + ${actionButtonsHtml} + ${descriptionHtml} + ${metaGridHtml} + ${filePathHtml} + ${trackListHtml} + ${adminResponseHtml} + `; + + // --- Footer with status action buttons --- + const safeId = parseInt(issue.id, 10); + let footerHtml = ''; + + if (admin) { + if (issue.status === 'open' || issue.status === 'in_progress') { + if (issue.status === 'open') { + footerHtml += ``; + } + footerHtml += ``; + footerHtml += ``; + } else { + footerHtml += ``; + } + footerHtml += ``; + } else { + if (issue.status === 'open') { + footerHtml += ``; + } + } + + footer.innerHTML = footerHtml; + + // --- Attach action button handlers --- + const dlBtn = document.getElementById('issue-action-download'); + if (dlBtn) { + dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName); + } + const wlBtn = document.getElementById('issue-action-wishlist'); + if (wlBtn) { + wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName); + } +} + +// --- Issue Action: Download Album --- +async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) { + const btn = document.getElementById('issue-action-download'); + if (!spotifyAlbumId && (!artistName || !albumName)) { + showToast('No album ID or artist/album info available for download', 'warning'); + return; + } + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); + } else { + // No Spotify album ID — search for the album by name + const query = `${artistName} ${albumName}`; + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const foundAlbum = searchData.spotify_albums?.[0]; + if (!foundAlbum || !foundAlbum.id) { + showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); + return; + } + const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); + } + + if (!response.ok) { + if (response.status === 401) throw new Error('Spotify not authenticated'); + throw new Error(`Failed to load album: ${response.status}`); + } + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + // Close the issue modal first + closeIssueDetailModal(); + + const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now(); + const virtualPlaylistId = `issue_download_${resolvedAlbumId}`; + + // Enrich tracks with album metadata + const enrichedTracks = albumData.tracks.map(track => ({ + ...track, + album: { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks + } + })); + + const playlistName = `[${artistName}] ${albumData.name}`; + const artistObject = { id: null, name: artistName }; + const fullAlbumObject = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumData.artists || [{ name: artistName }] + }; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + ); + + } catch (error) { + console.error('Issue download error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Download Album'; } + } +} + +// --- Issue Action: Add to Wishlist --- +async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) { + const btn = document.getElementById('issue-action-wishlist'); + if (!spotifyAlbumId && (!artistName || !albumName)) { + showToast('No album ID or artist/album info available', 'warning'); + return; + } + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); + } else { + // No Spotify album ID — search for the album by name + const query = `${artistName} ${albumName}`; + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const foundAlbum = searchData.spotify_albums?.[0]; + if (!foundAlbum || !foundAlbum.id) { + showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); + return; + } + const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); + } + + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + // Close issue modal and open wishlist modal + closeIssueDetailModal(); + + const albumArtists = albumData.artists || [{ name: artistName }]; + const album = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumArtists + }; + const artist = { id: null, name: artistName }; + + // Enrich tracks with album metadata — use album artist for wishlist grouping + // (Spotify returns per-track artists which can differ on compilations/soundtracks) + const tracks = albumData.tracks.map(t => ({ + ...t, + artists: albumArtists, + album: album + })); + + await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album'); + + } catch (error) { + console.error('Issue wishlist error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Add to Wishlist'; } + } +} + +async function updateIssueStatus(issueId, newStatus) { + const payload = { status: newStatus }; + + // Include admin response if present + const responseInput = document.getElementById('issue-detail-response-input'); + if (responseInput) { + payload.admin_response = responseInput.value.trim(); + } + + try { + const resp = await fetch(`/api/issues/${issueId}`, { + method: 'PUT', + headers: _issueHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (data.success) { + showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success'); + closeIssueDetailModal(); + // Refresh if on issues page + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to update issue', 'error'); + } + } catch (e) { + console.error('Failed to update issue:', e); + showToast('Failed to update issue', 'error'); + } +} + +async function deleteIssue(issueId) { + if (!confirm('Are you sure you want to delete this issue?')) return; + try { + const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() }); + const data = await resp.json(); + if (data.success) { + showToast('Issue deleted', 'success'); + closeIssueDetailModal(); + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to delete issue', 'error'); + } + } catch (e) { + console.error('Failed to delete issue:', e); + showToast('Failed to delete issue', 'error'); + } +} + +function closeIssueDetailModal() { + const overlay = document.getElementById('issue-detail-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +async function loadIssuesBadge() { + try { + const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success) return; + const badge = document.getElementById('issues-nav-badge'); + if (badge) { + const openCount = data.counts.open || 0; + badge.textContent = openCount; + badge.classList.toggle('hidden', openCount === 0); + } + } catch (e) {} +} + +// ===== END ISSUES PAGE ===== + // --- Helpers --- function _esc(str) { diff --git a/webui/static/style.css b/webui/static/style.css index 2e44f189..700ff091 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -320,6 +320,7 @@ body { } .nav-button { + position: relative; height: 44px; width: 216px; background: transparent; @@ -36265,4 +36266,1366 @@ tr.tag-diff-same { .docs-subsection-title { font-size: 17px; } +} + +/* ===== Enhanced Library — Read-only Meta Values ===== */ + +.enhanced-album-meta-value { + font-size: 13px; + color: rgba(255, 255, 255, 0.85); + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.06); + min-height: 28px; + display: flex; + align-items: center; +} + +/* ===== Enhanced Library — Report Issue Button ===== */ + +.enhanced-report-issue-btn { + background: rgba(255, 165, 0, 0.15); + border: 1px solid rgba(255, 165, 0, 0.3); + color: rgba(255, 165, 0, 0.9); + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.enhanced-report-issue-btn:hover { + background: rgba(255, 165, 0, 0.25); + border-color: rgba(255, 165, 0, 0.5); + color: #ffa500; +} + +.enhanced-track-report-btn { + background: none; + border: 1px solid rgba(255, 165, 0, 0.25); + color: rgba(255, 165, 0, 0.7); + width: 26px; + height: 26px; + border-radius: 50%; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + padding: 0; +} + +.enhanced-track-report-btn:hover { + background: rgba(255, 165, 0, 0.15); + border-color: rgba(255, 165, 0, 0.5); + color: #ffa500; +} + +/* ===== Issues Page ===== */ + +.issues-container { + max-width: 1000px; + margin: 0 auto; + padding: 24px 20px; +} + +.issues-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 16px; +} + +.issues-header-left { + flex: 1; + min-width: 200px; +} + +.issues-title { + font-size: 24px; + font-weight: 600; + color: #fff; + margin: 0 0 4px 0; +} + +.issues-subtitle { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); + margin: 0; +} + +.issues-header-right { + display: flex; + align-items: center; + gap: 10px; +} + +.issues-filters { + display: flex; + gap: 8px; +} + +.issues-filter-select { + 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; + 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; +} + +.issues-filter-select:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.issues-filter-select option { + background: #1a1a2e; + color: #fff; +} + +/* Issues Stats */ + +.issues-stats { + display: flex; + gap: 10px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.issues-stat-card { + 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; +} + +.issues-stat-number { + font-size: 22px; + font-weight: 700; + color: #fff; + line-height: 1.2; +} + +.issues-stat-label { + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 4px; +} + +.issues-stat-open { border-left: 3px solid #ffa500; } +.issues-stat-progress { border-left: 3px solid #4da6ff; } +.issues-stat-resolved { border-left: 3px solid #4ade80; } +.issues-stat-dismissed { border-left: 3px solid #888; } +.issues-stat-total { border-left: 3px solid rgba(var(--accent-light-rgb), 0.6); } + +/* Issues List */ + +.issues-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.issues-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; +} + +.issues-spinner { + 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); } +} + +.issues-empty { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.4); +} + +.issues-empty-icon { + font-size: 40px; + margin-bottom: 12px; + opacity: 0.5; +} + +.issues-empty-title { + font-size: 16px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 6px; +} + +.issues-empty-text { + font-size: 13px; + color: rgba(255, 255, 255, 0.35); +} + +/* Issue Card */ + +.issue-card { + display: flex; + align-items: stretch; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + padding: 14px 16px; + cursor: pointer; + transition: all 0.2s ease; + gap: 14px; +} + +.issue-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + transform: translateY(-1px); +} + +.issue-card-left { + flex-shrink: 0; + display: flex; + align-items: center; +} + +.issue-card-thumb { + width: 52px; + height: 52px; + border-radius: 8px; + object-fit: cover; +} + +.issue-card-thumb-placeholder { + 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; +} + +.issue-card-center { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} + +.issue-card-title-row { + display: flex; + align-items: center; + gap: 6px; +} + +.issue-card-category-icon { + font-size: 14px; + flex-shrink: 0; +} + +.issue-card-title { + font-size: 14px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.issue-card-responded { + font-size: 13px; + flex-shrink: 0; + opacity: 0.7; +} + +.issue-card-entity { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.issue-card-entity-type { + 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; +} + +.issue-card-entity-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.issue-card-meta-line { + color: rgba(255, 255, 255, 0.35); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.issue-card-description { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 500px; +} + +.issue-card-footer { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: rgba(255, 255, 255, 0.3); +} + +.issue-card-profile { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; +} + +.issue-card-date { + opacity: 0.8; +} + +.issue-card-right { + flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + gap: 8px; +} + +/* Status Badges */ + +.issue-status-badge { + padding: 3px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.issue-status-open { + background: rgba(255, 165, 0, 0.15); + color: #ffa500; + border: 1px solid rgba(255, 165, 0, 0.25); +} + +.issue-status-progress { + background: rgba(77, 166, 255, 0.15); + color: #4da6ff; + border: 1px solid rgba(77, 166, 255, 0.25); +} + +.issue-status-resolved { + background: rgba(74, 222, 128, 0.15); + color: #4ade80; + border: 1px solid rgba(74, 222, 128, 0.25); +} + +.issue-status-dismissed { + background: rgba(136, 136, 136, 0.15); + color: #888; + border: 1px solid rgba(136, 136, 136, 0.25); +} + +/* Priority Dots */ + +.issue-priority-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.issue-priority-low, .issue-priority-dot.issue-priority-low { + background: #666; +} + +.issue-priority-normal, .issue-priority-dot.issue-priority-normal { + background: #4da6ff; +} + +.issue-priority-high, .issue-priority-dot.issue-priority-high { + background: #ff4d4d; + box-shadow: 0 0 6px rgba(255, 77, 77, 0.4); +} + +/* Nav Badge */ + +.issues-nav-badge { + position: absolute; + top: 4px; + right: 4px; + background: #ffa500; + color: #000; + font-size: 10px; + font-weight: 700; + min-width: 16px; + height: 16px; + line-height: 16px; + text-align: center; + border-radius: 8px; + padding: 0 4px; +} + +/* ===== Report Issue Modal ===== */ + +.report-issue-modal { + max-width: 580px; + width: 95vw; +} + +.report-issue-entity-info { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 14px 16px; + margin-bottom: 18px; +} + +.report-issue-entity-name { + font-size: 15px; + font-weight: 500; + color: #fff; +} + +.report-issue-entity-artist { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin-top: 3px; +} + +.report-issue-section { + margin-bottom: 16px; +} + +.report-issue-label { + display: block; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 10px; +} + +.report-issue-optional { + font-weight: 400; + text-transform: none; + letter-spacing: 0; + color: rgba(255, 255, 255, 0.3); +} + +.report-issue-category-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; +} + +.report-issue-category-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 12px 10px; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.report-issue-category-card:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.15); +} + +.report-issue-category-card.selected { + background: rgba(var(--accent-light-rgb), 0.1); + border-color: rgba(var(--accent-light-rgb), 0.4); +} + +.report-issue-category-icon { + font-size: 22px; + margin-bottom: 6px; +} + +.report-issue-category-label { + font-size: 12px; + font-weight: 500; + color: #fff; + margin-bottom: 3px; +} + +.report-issue-category-desc { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + line-height: 1.4; +} + +.report-issue-input { + 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: 14px; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.report-issue-input:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.report-issue-textarea { + 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: 80px; + font-family: inherit; + transition: border-color 0.2s; + box-sizing: border-box; +} + +.report-issue-textarea:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.report-issue-priority-row { + display: flex; + gap: 8px; +} + +.report-issue-priority-btn { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); + border-radius: 8px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s ease; +} + +.report-issue-priority-btn:hover { + background: rgba(255, 255, 255, 0.08); +} + +.report-issue-priority-btn.selected { + background: rgba(var(--accent-light-rgb), 0.12); + border-color: rgba(var(--accent-light-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +/* ===== Issue Detail Modal ===== */ + +.issue-detail-modal { + max-width: 750px; + width: 95vw; + max-height: 85vh; +} + +.issue-detail-modal .enhanced-bulk-modal-body { + overflow-y: auto; + max-height: calc(85vh - 120px); +} + +/* Issue Detail — Hero Section (Enhanced Library Style) */ + +.issue-hero { + display: flex; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + margin-bottom: 14px; +} + +.issue-hero-art-group { + position: relative; + flex-shrink: 0; +} + +.issue-hero-artist-thumb { + 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); +} + +.issue-hero-album-art { + width: 140px; + height: 140px; + border-radius: 10px; + object-fit: cover; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +.issue-hero-album-placeholder { + 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); +} + +.issue-hero-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} + +.issue-hero-artist { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +.issue-hero-album { + font-size: 20px; + font-weight: 700; + color: #fff; + line-height: 1.2; +} + +.issue-hero-track-name { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + margin-top: 2px; +} + +.issue-hero-meta { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.issue-hero-genres { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.issue-hero-genre-tag { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.55); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +/* Issue Detail — Info Bar */ + +.issue-detail-info-bar { + 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; +} + +.issue-detail-info-left { + display: flex; + align-items: center; + gap: 10px; +} + +.issue-detail-info-right { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; +} + +/* Issue Detail — Action Buttons */ + +.issue-action-buttons { + display: flex; + gap: 8px; + margin-bottom: 14px; +} + +.issue-action-btn { + 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; +} + +.issue-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.issue-action-download { + background: rgba(var(--accent-light-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + border-color: rgba(var(--accent-light-rgb), 0.25); +} + +.issue-action-download:hover:not(:disabled) { + background: rgba(var(--accent-light-rgb), 0.22); + border-color: rgba(var(--accent-light-rgb), 0.4); +} + +.issue-action-wishlist { + background: rgba(255, 165, 0, 0.1); + color: #ffa500; + border-color: rgba(255, 165, 0, 0.2); +} + +.issue-action-wishlist:hover:not(:disabled) { + background: rgba(255, 165, 0, 0.2); + border-color: rgba(255, 165, 0, 0.35); +} + +/* Issue Detail — Track Duration */ + +.issue-detail-tracklist-dur { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); + flex-shrink: 0; + min-width: 36px; + text-align: right; +} + + +.issue-detail-section { + margin-bottom: 16px; +} + +.issue-detail-section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 8px; + font-weight: 500; +} + +.issue-detail-meta-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.issue-detail-category { + font-size: 13px; + color: rgba(255, 255, 255, 0.7); +} + +.issue-detail-date { + color: rgba(255, 255, 255, 0.35); +} + +.issue-detail-profile { + background: rgba(255, 255, 255, 0.06); + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; +} + +.issue-detail-title-text { + font-size: 15px; + font-weight: 500; + color: #fff; + margin-bottom: 8px; +} + +.issue-detail-description { + font-size: 13px; + color: rgba(255, 255, 255, 0.65); + line-height: 1.6; + white-space: pre-wrap; +} + +.issue-detail-no-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.25); + font-style: italic; +} + +/* Issue Detail — Snapshot */ + +.issue-detail-snapshot { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + padding: 12px 14px; +} + +.issue-detail-snapshot-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.issue-detail-snapshot-row:last-child { + border-bottom: none; +} + +.issue-detail-snapshot-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + flex-shrink: 0; + min-width: 70px; +} + +.issue-detail-snapshot-value { + font-size: 12px; + color: rgba(255, 255, 255, 0.75); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 350px; +} + +.issue-detail-snapshot-tracklist-header { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.35); + margin-top: 10px; + margin-bottom: 6px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.issue-detail-snapshot-track { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + padding: 2px 0; +} + +.issue-detail-snapshot-track-meta { + color: rgba(255, 255, 255, 0.3); + font-size: 11px; + margin-left: 6px; +} + + +/* Issue Detail — Metadata Grid */ + +.issue-detail-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 8px; +} + +.issue-meta-item { + 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; +} + +.issue-meta-icon { + font-size: 14px; + opacity: 0.5; + flex-shrink: 0; + width: 18px; + text-align: center; +} + +.issue-meta-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: rgba(255, 255, 255, 0.35); + flex-shrink: 0; +} + +.issue-meta-value { + 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; +} + +/* Issue Detail — Album Context (for track issues) */ + +.issue-detail-album-context { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; +} + +.issue-detail-album-context-thumb { + width: 44px; + height: 44px; + border-radius: 6px; + object-fit: cover; + flex-shrink: 0; +} + +.issue-detail-album-context-info { + flex: 1; + min-width: 0; +} + +.issue-detail-album-context-title { + font-size: 13px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.issue-detail-album-context-meta { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +/* Issue Detail — File Path */ + +.issue-detail-filepath { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + background: rgba(0, 0, 0, 0.2); + padding: 8px 10px; + border-radius: 6px; + font-family: monospace; + word-break: break-all; + border: 1px solid rgba(255, 255, 255, 0.04); +} + +/* Issue Detail — Track Listing (redesigned) */ + +.issue-detail-tracklist { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 10px; + padding: 6px 0; + max-height: 240px; + overflow-y: auto; +} + +.issue-detail-tracklist-disc { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: rgba(255, 255, 255, 0.35); + padding: 8px 14px 4px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.issue-detail-tracklist-disc:first-child { + border-top: none; + padding-top: 4px; +} + +.issue-detail-tracklist-row { + display: flex; + align-items: center; + padding: 4px 14px; + gap: 10px; + transition: background 0.15s; +} + +.issue-detail-tracklist-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.issue-detail-tracklist-num { + font-size: 12px; + color: rgba(255, 255, 255, 0.3); + min-width: 22px; + text-align: right; + flex-shrink: 0; +} + +.issue-detail-tracklist-title { + font-size: 13px; + color: rgba(255, 255, 255, 0.75); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.issue-detail-tracklist-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + + +.issue-detail-section-count { + font-weight: 400; + color: rgba(255, 255, 255, 0.3); + font-size: 10px; + text-transform: none; + letter-spacing: 0; + margin-left: 6px; +} + +/* Issue Detail — External Links */ + +.issue-ext-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.issue-ext-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.issue-ext-chip-type { + font-weight: 400; + font-size: 10px; + opacity: 0.7; +} + +.issue-ext-chip.ext-spotify { + background: rgba(30, 215, 96, 0.1); + color: #1ed760; + border-color: rgba(30, 215, 96, 0.2); +} + +.issue-ext-chip.ext-spotify:hover { + background: rgba(30, 215, 96, 0.2); +} + +.issue-ext-chip.ext-mb { + background: rgba(186, 81, 163, 0.1); + color: #d4a0cb; + border-color: rgba(186, 81, 163, 0.2); +} + +.issue-ext-chip.ext-mb:hover { + background: rgba(186, 81, 163, 0.2); +} + +.issue-ext-chip.ext-deezer { + background: rgba(160, 54, 255, 0.1); + color: #c88eff; + border-color: rgba(160, 54, 255, 0.2); +} + +.issue-ext-chip.ext-deezer:hover { + background: rgba(160, 54, 255, 0.2); +} + +.issue-ext-chip.ext-tidal { + background: rgba(0, 255, 255, 0.08); + color: #66ffff; + border-color: rgba(0, 255, 255, 0.15); +} + +.issue-ext-chip.ext-tidal:hover { + background: rgba(0, 255, 255, 0.15); +} + +.issue-ext-chip.ext-qobuz { + background: rgba(0, 130, 200, 0.1); + color: #5bb8e8; + border-color: rgba(0, 130, 200, 0.2); +} + +.issue-ext-chip.ext-qobuz:hover { + background: rgba(0, 130, 200, 0.2); +} + +/* Issue Detail — Responsive */ + +@media (max-width: 768px) { + .issue-hero { + flex-direction: column; + align-items: center; + text-align: center; + } + + .issue-hero-album-art, + .issue-hero-album-placeholder { + width: 120px; + height: 120px; + } + + .issue-hero-artist-thumb { + width: 40px; + height: 40px; + } + + .issue-hero-info { + align-items: center; + } + + .issue-hero-genres { + justify-content: center; + } + + .issue-hero-album { + font-size: 17px; + } + + .issue-detail-info-bar { + flex-direction: column; + align-items: flex-start; + } + + .issue-action-buttons { + flex-direction: column; + } + + .issue-action-btn { + justify-content: center; + } + + .issue-detail-meta-grid { + grid-template-columns: repeat(2, 1fr); + } + + .issue-meta-value { + max-width: 80px; + } + + .issue-ext-chips { + gap: 4px; + justify-content: center; + } + + .issue-ext-chip { + font-size: 11px; + padding: 3px 8px; + } +} + +/* Issue Detail — Admin Response */ + +.issue-detail-response-textarea { + 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; +} + +.issue-detail-response-textarea:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.issue-detail-admin-response { + background: rgba(74, 222, 128, 0.06); + border: 1px solid rgba(74, 222, 128, 0.15); + border-radius: 10px; + padding: 12px 14px; + font-size: 13px; + color: rgba(255, 255, 255, 0.75); + line-height: 1.6; + white-space: pre-wrap; +} + +/* Issue Detail — Footer Action Buttons */ + +.issue-btn-progress { + background: rgba(77, 166, 255, 0.15) !important; + border-color: rgba(77, 166, 255, 0.3) !important; + color: #4da6ff !important; +} + +.issue-btn-progress:hover { + background: rgba(77, 166, 255, 0.25) !important; +} + +.issue-btn-resolve { + background: rgba(74, 222, 128, 0.15) !important; + border-color: rgba(74, 222, 128, 0.3) !important; + color: #4ade80 !important; +} + +.issue-btn-resolve:hover { + background: rgba(74, 222, 128, 0.25) !important; +} + +.issue-btn-dismiss { + background: rgba(136, 136, 136, 0.15) !important; + border-color: rgba(136, 136, 136, 0.3) !important; + color: #aaa !important; +} + +.issue-btn-dismiss:hover { + background: rgba(136, 136, 136, 0.25) !important; +} + +.issue-btn-reopen { + background: rgba(255, 165, 0, 0.15) !important; + border-color: rgba(255, 165, 0, 0.3) !important; + color: #ffa500 !important; +} + +.issue-btn-reopen:hover { + background: rgba(255, 165, 0, 0.25) !important; +} + +.issue-btn-delete { + background: rgba(255, 77, 77, 0.1) !important; + border-color: rgba(255, 77, 77, 0.2) !important; + color: #ff6b6b !important; +} + +.issue-btn-delete:hover { + background: rgba(255, 77, 77, 0.2) !important; +} + +/* ===== Issues Page — Responsive ===== */ + +@media (max-width: 768px) { + .issues-header { + flex-direction: column; + } + + .issues-stats { + flex-wrap: wrap; + } + + .issues-stat-card { + min-width: 80px; + } + + .issue-card { + flex-wrap: wrap; + padding: 12px; + } + + .issue-card-left { + display: none; + } + + .issue-card-description { + max-width: 100%; + } + + .report-issue-category-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + } + + .issue-detail-header-info { + flex-direction: column; + } + + .issue-detail-snapshot-value { + max-width: 200px; + } } \ No newline at end of file