Add library issue reporting system with actionable detail modal

pull/253/head
Broque Thomas 1 month ago
parent 57a8bdd107
commit e1a5bf678a

@ -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()

@ -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/<int:issue_id>', 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/<int:issue_id>', 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/<int:issue_id>', 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:

@ -156,6 +156,11 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="nav-text">Settings</span>
</button>
<button class="nav-button" data-page="issues">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span class="nav-text">Issues</span>
<span class="issues-nav-badge hidden" id="issues-nav-badge">0</span>
</button>
<button class="nav-button" data-page="help">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span class="nav-text">Help & Docs</span>
@ -4562,6 +4567,85 @@
</div>
</div>
<!-- Issues Page -->
<div class="page" id="issues-page">
<div class="issues-container">
<div class="issues-header">
<div class="issues-header-left">
<h2 class="issues-title">Issues</h2>
<p class="issues-subtitle" id="issues-subtitle">Track and resolve library problems</p>
</div>
<div class="issues-header-right">
<div class="issues-filters" id="issues-filters">
<select id="issues-filter-status" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Status</option>
<option value="open" selected>Open</option>
<option value="in_progress">In Progress</option>
<option value="resolved">Resolved</option>
<option value="dismissed">Dismissed</option>
</select>
<select id="issues-filter-category" class="issues-filter-select" onchange="loadIssuesPage()">
<option value="">All Categories</option>
<optgroup label="Track Issues">
<option value="wrong_track">Wrong Track</option>
<option value="wrong_artist">Wrong Artist</option>
<option value="wrong_album">Wrong Album</option>
<option value="audio_quality">Audio Quality</option>
</optgroup>
<optgroup label="Album Issues">
<option value="wrong_cover">Wrong Cover Art</option>
<option value="duplicate_tracks">Duplicate Tracks</option>
<option value="missing_tracks">Missing Tracks</option>
<option value="incomplete_album">Incomplete Album</option>
</optgroup>
<optgroup label="Both">
<option value="wrong_metadata">Wrong Metadata</option>
<option value="other">Other</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="issues-stats" id="issues-stats"></div>
<div class="issues-list" id="issues-list">
<div class="issues-empty">Loading issues...</div>
</div>
</div>
</div>
<!-- Report Issue Modal -->
<div class="modal-overlay hidden" id="report-issue-overlay">
<div class="enhanced-bulk-modal report-issue-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="report-issue-title">Report an Issue</h3>
<button class="enhanced-bulk-modal-close" onclick="closeReportIssueModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="report-issue-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeReportIssueModal()">Cancel</button>
<button class="enhanced-bulk-btn primary" id="report-issue-submit-btn" onclick="submitIssue()">Submit Issue</button>
</div>
</div>
</div>
<!-- Issue Detail Modal (Admin) -->
<div class="modal-overlay hidden" id="issue-detail-overlay">
<div class="enhanced-bulk-modal issue-detail-modal">
<div class="enhanced-bulk-modal-header">
<h3 id="issue-detail-title">Issue Details</h3>
<button class="enhanced-bulk-modal-close" onclick="closeIssueDetailModal()">&times;</button>
</div>
<div class="enhanced-bulk-modal-body" id="issue-detail-body">
<!-- Populated dynamically -->
</div>
<div class="enhanced-bulk-modal-footer" id="issue-detail-footer">
<button class="enhanced-bulk-btn secondary" onclick="closeIssueDetailModal()">Close</button>
</div>
</div>
</div>
<!-- Help & Docs Page -->
<div class="page" id="help-page">
<div class="docs-layout">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save