Add centralized Downloads page with live status across all sources

New sidebar page showing every download task across the app in a unified
live-updating list. Tracks from Sync, Discover, Artists, Search, and
Wishlist all appear in one place.

Features:
- Filter pills: All / Active / Queued / Completed / Failed
- Section headers grouping by status category
- Track position (3 of 19) for album/playlist batches
- Album art, artist/album metadata, batch context, error messages
- Status dots with accent glow for active, green for complete, red fail
- Clear Completed button removes terminal items from tracker
- Nav badge shows active download count from any page via WebSocket

Fixes artist [object Object] display — handles all format variations
(list of dicts, list of strings, dict, string) for artist and album
fields in the API response.
This commit is contained in:
Broque Thomas 2026-04-11 23:58:22 -07:00
parent ce8c3b9cbb
commit 7798f56885
4 changed files with 738 additions and 2 deletions

View file

@ -4630,12 +4630,20 @@ def get_status():
download_mode = config_manager.get('download_source.mode', 'hybrid')
_status_cache['soulseek']['source'] = download_mode
# Count active downloads for nav badge
active_dl_count = 0
with tasks_lock:
for t in download_tasks.values():
if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'):
active_dl_count += 1
status_data = {
'spotify': _status_cache['spotify'],
'media_server': _status_cache['media_server'],
'soulseek': _status_cache['soulseek'],
'active_media_server': active_server,
'enrichment': _get_enrichment_status()
'enrichment': _get_enrichment_status(),
'active_downloads': active_dl_count,
}
return jsonify(status_data)
except Exception as e:
@ -29180,6 +29188,133 @@ def get_batched_download_statuses():
traceback.print_exc()
return jsonify({"error": str(e)}), 500
@app.route('/api/downloads/all', methods=['GET'])
def get_all_downloads_unified():
"""
Unified downloads list for the centralized Downloads page.
Returns a flat list of all download tasks across all batches,
sorted: downloading/searching first, then queued, then completed/failed.
"""
try:
limit = int(request.args.get('limit', 200))
status_priority = {
'downloading': 0, 'searching': 1, 'post_processing': 2,
'queued': 3, 'pending': 3,
'completed': 4, 'skipped': 5, 'already_owned': 5,
'not_found': 6, 'failed': 7, 'cancelled': 8,
}
items = []
with tasks_lock:
for task_id, task in download_tasks.items():
track_info = task.get('track_info') or {}
batch_id = task.get('batch_id', '')
batch = download_batches.get(batch_id, {})
# Extract track metadata — handle all format variations
title = ''
artist = ''
album = ''
artwork = ''
if isinstance(track_info, dict):
title = track_info.get('title') or track_info.get('name') or track_info.get('track_name') or ''
# Artist can be: string, list of strings, list of dicts with 'name'
raw_artist = track_info.get('artist') or track_info.get('artist_name') or track_info.get('artists') or ''
if isinstance(raw_artist, list):
parts = []
for a in raw_artist:
if isinstance(a, dict):
parts.append(a.get('name', ''))
else:
parts.append(str(a))
artist = ', '.join(p for p in parts if p)
elif isinstance(raw_artist, dict):
artist = raw_artist.get('name', '')
else:
artist = str(raw_artist) if raw_artist else ''
# Album can be: string or dict with 'name'
raw_album = track_info.get('album') or track_info.get('album_name') or ''
if isinstance(raw_album, dict):
album = raw_album.get('name', '')
else:
album = str(raw_album) if raw_album else ''
artwork = track_info.get('artwork_url') or track_info.get('image_url') or track_info.get('album_art') or ''
# Try album images
if not artwork:
raw_alb = track_info.get('album')
if isinstance(raw_alb, dict):
images = raw_alb.get('images') or []
if images and isinstance(images, list) and len(images) > 0:
artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0])
status = task.get('status', 'queued')
items.append({
'task_id': task_id,
'title': title,
'artist': artist,
'album': album,
'artwork': artwork,
'status': status,
'error': task.get('error_message'),
'batch_id': batch_id,
'batch_name': batch.get('playlist_name') or batch.get('album_name') or '',
'batch_source': batch.get('source_page') or batch.get('initiated_from') or '',
'track_index': task.get('track_index', 0),
'batch_total': len(batch.get('queue', [])),
'timestamp': task.get('status_change_time', 0),
'priority': status_priority.get(status, 9),
})
# Sort: active first (by priority), then by timestamp desc within each group
items.sort(key=lambda x: (x['priority'], -x['timestamp']))
return jsonify({
'success': True,
'downloads': items[:limit],
'total': len(items),
'timestamp': time.time(),
})
except Exception as e:
logger.error(f"Error getting unified downloads: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/downloads/clear-completed', methods=['POST'])
def clear_completed_downloads():
"""Remove completed/failed/cancelled tasks from the download tracker."""
try:
terminal_statuses = {'completed', 'failed', 'not_found', 'cancelled', 'skipped', 'already_owned'}
cleared = 0
with tasks_lock:
task_ids_to_remove = [
tid for tid, task in download_tasks.items()
if task.get('status') in terminal_statuses
]
for tid in task_ids_to_remove:
del download_tasks[tid]
cleared += 1
# Also clean up empty batches
empty_batches = []
for bid, batch in download_batches.items():
remaining = [t for t in batch.get('queue', []) if t in download_tasks]
if not remaining:
empty_batches.append(bid)
else:
batch['queue'] = remaining
for bid in empty_batches:
del download_batches[bid]
if bid in batch_locks:
del batch_locks[bid]
return jsonify({'success': True, 'cleared': cleared})
except Exception as e:
logger.error(f"Error clearing completed downloads: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/downloads/cancel_task', methods=['POST'])
def cancel_download_task():
"""
@ -51793,12 +51928,23 @@ def _build_status_payload():
if cooldown_remaining > 0:
spotify_data['post_ban_cooldown'] = cooldown_remaining
# Count active downloads for nav badge
active_dl_count = 0
try:
with tasks_lock:
for t in download_tasks.values():
if t.get('status') in ('downloading', 'searching', 'post_processing', 'queued', 'pending'):
active_dl_count += 1
except Exception:
pass
return {
'spotify': spotify_data,
'media_server': _status_cache.get('media_server', {}),
'soulseek': soulseek_data,
'active_media_server': config_manager.get_active_media_server(),
'enrichment': _get_enrichment_status()
'enrichment': _get_enrichment_status(),
'active_downloads': active_dl_count,
}
def _build_watchlist_count_payload(profile_id=1):

View file

@ -102,6 +102,7 @@
<option value="discover">Discover</option>
<option value="artists">Artists</option>
<option value="automations">Automations</option>
<option value="active-downloads">Downloads</option>
<option value="library">Library</option>
<option value="stats">Listening Stats</option>
<option value="playlist-explorer">Playlist Explorer</option>
@ -116,6 +117,7 @@
<label><input type="checkbox" value="discover" checked> Discover</label>
<label><input type="checkbox" value="artists" checked> Artists</label>
<label><input type="checkbox" value="automations" checked> Automations</label>
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
<label><input type="checkbox" value="library" checked> Library</label>
<label><input type="checkbox" value="stats" checked> Listening Stats</label>
<label><input type="checkbox" value="playlist-explorer" checked> Playlist Explorer</label>
@ -214,6 +216,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"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
<span class="nav-text">Automations</span>
</button>
<button class="nav-button" data-page="active-downloads">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
<span class="dl-nav-badge hidden" id="dl-nav-badge">0</span>
</button>
<button class="nav-button" data-page="library">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg></span>
<span class="nav-text">Library</span>
@ -2626,6 +2633,31 @@
</div>
</div>
<!-- Active Downloads Page -->
<div class="page" id="active-downloads-page">
<div class="adl-container">
<div class="adl-header">
<h2 class="adl-title"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Downloads</h2>
<div class="adl-controls">
<div class="adl-filter-pills" id="adl-filter-pills">
<button class="adl-pill active" data-filter="all" onclick="adlSetFilter('all')">All</button>
<button class="adl-pill" data-filter="active" onclick="adlSetFilter('active')">Active</button>
<button class="adl-pill" data-filter="queued" onclick="adlSetFilter('queued')">Queued</button>
<button class="adl-pill" data-filter="completed" onclick="adlSetFilter('completed')">Completed</button>
<button class="adl-pill" data-filter="failed" onclick="adlSetFilter('failed')">Failed</button>
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
</div>
</div>
</div>
<div class="adl-list" id="adl-list">
<div class="adl-empty" id="adl-empty">No downloads yet. Start one from Search, Sync, Discover, or Artists.</div>
</div>
</div>
</div>
<!-- Library Page -->
<div class="page" id="library-page">
<div class="library-container">

View file

@ -386,6 +386,9 @@ function handleServiceStatusUpdate(data) {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Update downloads nav badge from status push
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
// Update enrichment service cards
if (data.enrichment) renderEnrichmentCards(data.enrichment);
@ -2935,6 +2938,9 @@ async function loadPageData(pageId) {
restoreArtistsPageState();
}
break;
case 'active-downloads':
loadActiveDownloadsPage();
break;
case 'library':
// Check if we should return to artist detail view instead of list
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
@ -38727,6 +38733,9 @@ async function fetchAndUpdateServiceStatus() {
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Update downloads nav badge
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
// Update enrichment service cards
if (data.enrichment) renderEnrichmentCards(data.enrichment);
@ -72715,3 +72724,216 @@ function _syncDetailFilter(btn, filter) {
}
});
}
// ============================================
// ACTIVE DOWNLOADS PAGE — Centralized Live View
// ============================================
let _adlPoller = null;
let _adlFilter = 'all';
let _adlData = [];
function loadActiveDownloadsPage() {
_adlFetch();
// Poll every 2 seconds while on this page
if (_adlPoller) clearInterval(_adlPoller);
_adlPoller = setInterval(() => {
if (currentPage === 'active-downloads') _adlFetch();
else { clearInterval(_adlPoller); _adlPoller = null; }
}, 2000);
}
function adlSetFilter(filter) {
_adlFilter = filter;
document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter));
_adlRender();
}
async function _adlFetch() {
try {
const resp = await fetch('/api/downloads/all?limit=300');
const data = await resp.json();
if (data.success) {
_adlData = data.downloads || [];
_adlRender();
_adlUpdateBadge();
}
} catch (e) {
console.error('Downloads page fetch error:', e);
}
}
function _adlUpdateBadge() {
const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length;
_updateDlNavBadge(activeCount);
}
function _updateDlNavBadge(count) {
const badge = document.getElementById('dl-nav-badge');
if (badge) {
if (count > 0) {
badge.textContent = count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
}
function _adlRender() {
const list = document.getElementById('adl-list');
const empty = document.getElementById('adl-empty');
const countEl = document.getElementById('adl-count');
if (!list) return;
// Apply filter
const activeStatuses = ['downloading', 'searching', 'post_processing'];
const queuedStatuses = ['queued'];
const completedStatuses = ['completed', 'skipped', 'already_owned'];
const failedStatuses = ['failed', 'not_found', 'cancelled'];
let filtered = _adlData;
if (_adlFilter === 'active') filtered = _adlData.filter(d => activeStatuses.includes(d.status));
else if (_adlFilter === 'queued') filtered = _adlData.filter(d => queuedStatuses.includes(d.status));
else if (_adlFilter === 'completed') filtered = _adlData.filter(d => completedStatuses.includes(d.status));
else if (_adlFilter === 'failed') filtered = _adlData.filter(d => failedStatuses.includes(d.status));
const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length;
if (countEl) {
const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length;
const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length;
const total = _adlData.length;
const parts = [];
if (activeN > 0) parts.push(`${activeN} active`);
if (queuedN > 0) parts.push(`${queuedN} queued`);
parts.push(`${total} total`);
countEl.textContent = parts.join(' / ');
}
// Show/hide clear button
const clearBtn = document.getElementById('adl-clear-btn');
if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none';
if (filtered.length === 0) {
if (empty) empty.style.display = '';
// Clear any existing rows but keep the empty message
list.querySelectorAll('.adl-row').forEach(r => r.remove());
return;
}
if (empty) empty.style.display = 'none';
// Group by status category for section headers
const groups = { active: [], queued: [], completed: [], failed: [] };
for (const dl of filtered) {
const cls = _adlStatusClass(dl.status);
if (cls === 'active') groups.active.push(dl);
else if (cls === 'queued') groups.queued.push(dl);
else if (cls === 'completed') groups.completed.push(dl);
else groups.failed.push(dl);
}
let html = '';
const sections = [
{ key: 'active', label: 'Active', items: groups.active },
{ key: 'queued', label: 'Queued', items: groups.queued },
{ key: 'completed', label: 'Completed', items: groups.completed },
{ key: 'failed', label: 'Failed', items: groups.failed },
];
for (const section of sections) {
if (section.items.length === 0) continue;
// Only show section headers in "all" filter mode
if (_adlFilter === 'all') {
html += `<div class="adl-section-header">${section.label} (${section.items.length})</div>`;
}
for (const dl of section.items) {
const statusClass = _adlStatusClass(dl.status);
const statusLabel = _adlStatusLabel(dl.status);
const title = _adlEsc(dl.title || 'Unknown Track');
const artist = _adlEsc(dl.artist || '');
const album = _adlEsc(dl.album || '');
const batchName = _adlEsc(dl.batch_name || '');
const error = dl.error ? _adlEsc(dl.error) : '';
const meta = [artist, album].filter(Boolean).join(' \u00B7 ');
const artHtml = dl.artwork
? `<img class="adl-row-art" src="${_adlEsc(dl.artwork)}" alt="" onerror="this.style.display='none'">`
: '<div class="adl-row-art adl-row-art-empty"></div>';
// Track position: "3 of 19"
const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : '';
html += `<div class="adl-row adl-row-${statusClass}" data-task-id="${dl.task_id}">
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
${batchName ? `<div class="adl-row-batch">${batchName}${posText ? ' &middot; Track ' + posText : ''}</div>` : ''}
${error ? `<div class="adl-row-error">${error}</div>` : ''}
</div>
<div class="adl-row-status ${statusClass}">
<span class="adl-status-dot ${statusClass}"></span>
${statusLabel}
</div>
</div>`;
}
}
// Preserve empty element, inject rows
const emptyEl = document.getElementById('adl-empty');
const emptyHtml = emptyEl ? emptyEl.outerHTML : '';
list.innerHTML = emptyHtml + html;
const newEmpty = document.getElementById('adl-empty');
if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : '';
}
function _adlStatusClass(status) {
switch (status) {
case 'downloading': case 'searching': case 'post_processing': return 'active';
case 'queued': case 'pending': return 'queued';
case 'completed': case 'skipped': case 'already_owned': return 'completed';
case 'failed': case 'not_found': return 'failed';
case 'cancelled': return 'cancelled';
default: return 'queued';
}
}
function _adlStatusLabel(status) {
switch (status) {
case 'downloading': return '<span class="adl-spinner"></span>Downloading';
case 'searching': return '<span class="adl-spinner"></span>Searching';
case 'post_processing': return '<span class="adl-spinner"></span>Processing';
case 'queued': case 'pending': return 'Queued';
case 'completed': return 'Completed';
case 'skipped': return 'Skipped';
case 'already_owned': return 'Owned';
case 'failed': return 'Failed';
case 'not_found': return 'Not Found';
case 'cancelled': return 'Cancelled';
default: return status;
}
}
function _adlEsc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
async function adlClearCompleted() {
try {
const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' });
const data = await resp.json();
if (data.success) {
if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success');
_adlFetch();
}
} catch (e) {
console.error('Error clearing completed downloads:', e);
}
}
window.adlSetFilter = adlSetFilter;
window.adlClearCompleted = adlClearCompleted;

View file

@ -55178,3 +55178,339 @@ body.reduce-effects *::after {
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}
/* ============================================
ACTIVE DOWNLOADS PAGE Premium Glassmorphic
============================================ */
.adl-container {
padding: 28px 32px;
max-width: 960px;
}
.adl-header {
margin-bottom: 24px;
}
.adl-title {
font-size: 1.5rem;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
letter-spacing: -0.02em;
}
.adl-title svg {
color: rgb(var(--accent-rgb));
filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.3));
}
.adl-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.adl-filter-pills {
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
padding: 3px;
}
.adl-pill {
padding: 6px 16px;
border-radius: 8px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.45);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.adl-pill:hover {
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
}
.adl-pill.active {
background: rgba(var(--accent-rgb), 0.15);
color: rgb(var(--accent-light-rgb));
box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15);
}
.adl-count {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.3);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.adl-clear-btn {
padding: 5px 12px;
border-radius: 7px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.4);
font-size: 0.72rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.adl-clear-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.12);
}
.adl-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.adl-empty {
text-align: center;
padding: 64px 20px;
color: rgba(255, 255, 255, 0.2);
font-size: 0.9rem;
}
/* ---- Section Headers ---- */
.adl-section-header {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.2);
padding: 12px 14px 4px;
}
/* ---- Download Row ---- */
.adl-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.018);
border: 1px solid rgba(255, 255, 255, 0.035);
border-radius: 10px;
transition: all 0.2s ease;
}
.adl-row:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.07);
}
/* Active row accent glow */
.adl-row.adl-row-active {
border-color: rgba(var(--accent-rgb), 0.18);
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.04) 0%, rgba(var(--accent-rgb), 0.01) 100%);
box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.06);
}
/* Completed row subtle green */
.adl-row.adl-row-completed {
border-color: rgba(34, 197, 94, 0.1);
}
/* Failed row subtle red */
.adl-row.adl-row-failed {
border-color: rgba(239, 68, 68, 0.1);
}
.adl-row-art {
width: 44px;
height: 44px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.adl-row-art-empty {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
}
.adl-row-info {
flex: 1;
min-width: 0;
}
.adl-row-title {
font-size: 0.88rem;
font-weight: 600;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.adl-row-meta {
font-size: 0.78rem;
color: rgba(255, 255, 255, 0.4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.adl-row-batch {
font-size: 0.7rem;
color: rgba(var(--accent-rgb), 0.55);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adl-row-error {
font-size: 0.7rem;
color: rgba(239, 68, 68, 0.65);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Status Badges ---- */
.adl-row-status {
font-size: 0.76rem;
font-weight: 500;
white-space: nowrap;
min-width: 95px;
text-align: right;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.adl-row-status.active {
color: rgb(var(--accent-light-rgb));
}
.adl-row-status.queued {
color: rgba(255, 255, 255, 0.3);
}
.adl-row-status.completed {
color: #22c55e;
}
.adl-row-status.failed {
color: #ef4444;
}
.adl-row-status.cancelled {
color: rgba(255, 255, 255, 0.2);
text-decoration: line-through;
text-decoration-color: rgba(255, 255, 255, 0.1);
}
/* Status dot indicator */
.adl-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.adl-status-dot.active {
background: rgb(var(--accent-rgb));
box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5);
animation: adlPulse 1.5s ease infinite;
}
.adl-status-dot.queued { background: rgba(255, 255, 255, 0.2); }
.adl-status-dot.completed { background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.3); }
.adl-status-dot.failed { background: #ef4444; box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); }
.adl-status-dot.cancelled { background: rgba(255, 255, 255, 0.15); }
@keyframes adlPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ---- Spinner ---- */
.adl-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 1.5px solid rgba(var(--accent-rgb), 0.2);
border-top-color: rgb(var(--accent-rgb));
border-radius: 50%;
animation: adlSpin 0.6s linear infinite;
vertical-align: middle;
}
@keyframes adlSpin {
to { transform: rotate(360deg); }
}
/* ---- Nav Badge ---- */
.dl-nav-badge {
position: absolute;
top: 4px;
right: 8px;
background: rgb(var(--accent-rgb));
color: #fff;
font-size: 0.6rem;
font-weight: 700;
min-width: 16px;
height: 16px;
line-height: 16px;
border-radius: 8px;
text-align: center;
padding: 0 4px;
box-shadow: 0 2px 6px rgba(var(--accent-rgb), 0.3);
}
/* ---- Responsive ---- */
@media (max-width: 600px) {
.adl-container {
padding: 16px 12px;
}
.adl-controls {
flex-direction: column;
align-items: flex-start;
}
.adl-filter-pills {
flex-wrap: wrap;
}
.adl-row-art {
width: 36px;
height: 36px;
border-radius: 6px;
}
.adl-row-status {
min-width: 70px;
}
}