Skip to content

Commit 74c94d3

Browse files
author
sgerner
committed
add download button states
1 parent 1cda863 commit 74c94d3

1 file changed

Lines changed: 170 additions & 33 deletions

File tree

web/index.html

Lines changed: 170 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -356,11 +356,14 @@ <h1 class="text-lg font-bold text-white leading-tight">Librarr</h1>
356356
<div class="flex items-center justify-between mb-6">
357357
<h2 class="text-lg font-semibold text-white" data-i18n="downloads_title">Downloads</h2>
358358
<div class="flex gap-2">
359-
<button onclick="refreshDownloads()" class="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5">
360-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
359+
<button id="downloads-refresh-btn" onclick="refreshDownloads(true)" class="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5">
360+
<svg id="downloads-refresh-icon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
361361
<span data-i18n="refresh">Refresh</span>
362362
</button>
363-
<button onclick="clearCompleted()" class="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors" data-i18n="clear_completed">Clear Completed</button>
363+
<button id="downloads-clear-btn" onclick="clearCompleted()" class="px-3 py-1.5 text-sm bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors flex items-center gap-1.5">
364+
<svg id="downloads-clear-icon" class="w-4 h-4 hidden spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle><path class="opacity-90" fill="currentColor" d="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z"></path></svg>
365+
<span data-i18n="clear_completed">Clear Completed</span>
366+
</button>
364367
</div>
365368
</div>
366369

@@ -832,6 +835,8 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
832835
no_results: 'No results found',
833836
no_results_hint: 'Try different keywords or check your spelling',
834837
download: 'Download',
838+
download_added: 'Added',
839+
download_failed_state: 'Failed',
835840
search_failed: 'Search failed: {msg}',
836841
n_seeds: '{n} seed',
837842
n_leech: '{n} leech',
@@ -1024,6 +1029,8 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
10241029
no_results: 'Ничего не найдено',
10251030
no_results_hint: 'Попробуйте другие ключевые слова или проверьте написание',
10261031
download: 'Скачать',
1032+
download_added: 'Добавлено',
1033+
download_failed_state: 'Ошибка',
10271034
search_failed: 'Ошибка поиска: {msg}',
10281035
n_seeds: '{n} сид.',
10291036
n_leech: '{n} лич.',
@@ -1222,6 +1229,11 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
12221229
searchTab: 'ebooks',
12231230
libraryTab: 'ebooks',
12241231
searchResults: [],
1232+
pendingDownloads: new Set(),
1233+
downloadOutcomes: new Map(),
1234+
downloadOutcomeTimers: new Map(),
1235+
downloadJobs: [],
1236+
pendingRetryDownloads: new Set(),
12251237
sortMode: 'relevance',
12261238
libraryPage: 1,
12271239
libraryPages: 1,
@@ -1772,6 +1784,9 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
17721784

17731785
function renderBookCard(result, index) {
17741786
const src = SOURCE_COLORS[result.source] || { bg: '#475569', text: 'white', label: result.source || 'Unknown' };
1787+
const downloadKey = getDownloadKey(result);
1788+
const isDownloading = state.pendingDownloads.has(downloadKey);
1789+
const downloadOutcome = state.downloadOutcomes.get(downloadKey);
17751790
const coverHtml = result.cover_url
17761791
? `<img src="${escapeHtml(result.cover_url)}" alt="" class="w-full h-48 object-cover" loading="lazy" onerror="this.outerHTML=makePlaceholder('${escapeHtml(result.title || '')}', ${index})">`
17771792
: makePlaceholderHtml(result.title || '?', index);
@@ -1783,6 +1798,26 @@ <h3 class="text-sm font-semibold text-slate-300 uppercase tracking-wider mb-4" d
17831798
const format = result.format ? `<span class="text-slate-500 text-xs uppercase">${escapeHtml(result.format)}</span>` : '';
17841799
const indexer = result.indexer ? `<span class="text-slate-600 text-xs">${escapeHtml(result.indexer)}</span>` : '';
17851800

1801+
const buttonState = isDownloading ? 'loading' : (downloadOutcome ? downloadOutcome.status : 'idle');
1802+
const buttonStyles = {
1803+
idle: 'bg-indigo-600 hover:bg-indigo-500 text-white',
1804+
loading: 'bg-indigo-500/70 text-white cursor-not-allowed',
1805+
success: 'bg-emerald-600 hover:bg-emerald-500 text-white cursor-default',
1806+
error: 'bg-rose-600 hover:bg-rose-500 text-white cursor-pointer',
1807+
};
1808+
const buttonText = {
1809+
idle: t('download'),
1810+
loading: t('loading'),
1811+
success: t('download_added'),
1812+
error: t('download_failed_state'),
1813+
};
1814+
const buttonIcon = {
1815+
idle: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>`,
1816+
loading: `<svg class="w-4 h-4 spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle><path class="opacity-90" fill="currentColor" d="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z"></path></svg>`,
1817+
success: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>`,
1818+
error: `<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 8v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>`,
1819+
};
1820+
17861821
return `
17871822
<div class="book-card bg-slate-900 rounded-xl overflow-hidden border border-slate-800 hover:border-slate-600 flex flex-col">
17881823
<div class="relative">
@@ -1795,9 +1830,13 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
17951830
<div class="flex items-center gap-2 flex-wrap mt-auto mb-2">
17961831
${seeders}${leechers}${size}${format}${indexer}
17971832
</div>
1798-
<button onclick='startDownload(${JSON.stringify(result).replace(/'/g, "&#39;")})' class="w-full bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5">
1799-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
1800-
${t('download')}
1833+
<button
1834+
onclick='startDownload(${JSON.stringify(result).replace(/'/g, "&#39;")})'
1835+
${buttonState === 'idle' || buttonState === 'error' ? '' : 'disabled aria-busy="true"'}
1836+
class="w-full ${buttonStyles[buttonState] || buttonStyles.idle} text-white text-sm font-medium py-1.5 rounded-lg transition-colors flex items-center justify-center gap-1.5 disabled:opacity-100"
1837+
>
1838+
${buttonIcon[buttonState] || buttonIcon.idle}
1839+
${buttonText[buttonState] || buttonText.idle}
18011840
</button>
18021841
</div>
18031842
</div>
@@ -1828,14 +1867,50 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
18281867
return '';
18291868
}
18301869

1870+
function getDownloadKey(result) {
1871+
return [
1872+
result.source || '',
1873+
result.download_url || '',
1874+
result.url || '',
1875+
result.abb_url || '',
1876+
result.info_hash || '',
1877+
result.magnet || '',
1878+
result.md5 || '',
1879+
result.title || '',
1880+
result.author || '',
1881+
].join('|');
1882+
}
1883+
1884+
function setDownloadOutcome(downloadKey, status) {
1885+
const prevTimer = state.downloadOutcomeTimers.get(downloadKey);
1886+
if (prevTimer) clearTimeout(prevTimer);
1887+
1888+
state.downloadOutcomes.set(downloadKey, { status });
1889+
renderSearchResults();
1890+
1891+
const timer = setTimeout(() => {
1892+
state.downloadOutcomes.delete(downloadKey);
1893+
state.downloadOutcomeTimers.delete(downloadKey);
1894+
renderSearchResults();
1895+
}, 2500);
1896+
state.downloadOutcomeTimers.set(downloadKey, timer);
1897+
}
1898+
18311899
// ============================================================
18321900
// DOWNLOAD
18331901
// ============================================================
18341902
async function startDownload(result) {
1903+
const downloadKey = getDownloadKey(result);
1904+
if (state.pendingDownloads.has(downloadKey)) return;
1905+
1906+
state.pendingDownloads.add(downloadKey);
1907+
renderSearchResults();
1908+
18351909
try {
18361910
const body = {
18371911
title: result.title,
18381912
download_url: result.download_url || result.url || '',
1913+
abb_url: result.abb_url || '',
18391914
source: result.source,
18401915
md5: result.md5 || '',
18411916
author: result.author || '',
@@ -1849,57 +1924,97 @@ <h3 class="text-sm font-semibold text-white line-clamp-2 mb-1" title="${escapeHt
18491924
});
18501925

18511926
if (data.success || data.job_id) {
1927+
setDownloadOutcome(downloadKey, 'success');
18521928
showToast(t('download_started', {title: result.title}), 'success');
18531929
} else {
1930+
setDownloadOutcome(downloadKey, 'error');
18541931
showToast(t('download_failed', {msg: data.error || t('unknown_error')}), 'error');
18551932
}
18561933
} catch (err) {
18571934
if (err.message !== 'Unauthorized') {
1935+
setDownloadOutcome(downloadKey, 'error');
18581936
showToast(t('download_failed', {msg: err.message}), 'error');
18591937
}
1938+
} finally {
1939+
state.pendingDownloads.delete(downloadKey);
1940+
renderSearchResults();
18601941
}
18611942
}
18621943

1863-
async function refreshDownloads() {
1864-
try {
1865-
const data = await apiJson('/api/downloads');
1866-
const jobs = data.jobs || [];
1867-
const container = document.getElementById('downloads-list');
1868-
const emptyEl = document.getElementById('downloads-empty');
1869-
1870-
// Update badge
1871-
const activeCount = jobs.filter(j => j.status === 'downloading' || j.status === 'queued' || j.status === 'searching' || j.status === 'organizing' || j.status === 'importing').length;
1872-
const badge = document.getElementById('dl-badge');
1873-
if (activeCount > 0) {
1874-
badge.textContent = activeCount;
1875-
badge.classList.remove('hidden');
1876-
} else {
1877-
badge.classList.add('hidden');
1878-
}
1944+
function setDownloadsRefreshLoading(loading) {
1945+
const button = document.getElementById('downloads-refresh-btn');
1946+
const icon = document.getElementById('downloads-refresh-icon');
1947+
if (!button || !icon) return;
18791948

1880-
if (jobs.length === 0) {
1881-
container.innerHTML = '';
1882-
emptyEl.classList.remove('hidden');
1883-
return;
1884-
}
1949+
button.disabled = loading;
1950+
if (loading) {
1951+
button.setAttribute('aria-busy', 'true');
1952+
icon.classList.add('spin');
1953+
} else {
1954+
button.removeAttribute('aria-busy');
1955+
icon.classList.remove('spin');
1956+
}
1957+
}
18851958

1886-
emptyEl.classList.add('hidden');
1887-
container.innerHTML = jobs.map(renderDownloadJob).join('');
1959+
async function refreshDownloads(manual = false) {
1960+
if (manual) setDownloadsRefreshLoading(true);
1961+
1962+
try {
1963+
const data = await apiJson('/api/downloads');
1964+
state.downloadJobs = data.jobs || [];
1965+
renderDownloadList();
18881966
} catch (err) {
18891967
if (err.message !== 'Unauthorized') {
18901968
showToast(t('failed_load_downloads'), 'error');
18911969
}
1970+
} finally {
1971+
if (manual) setDownloadsRefreshLoading(false);
1972+
}
1973+
}
1974+
1975+
function renderDownloadList() {
1976+
const jobs = state.downloadJobs || [];
1977+
const container = document.getElementById('downloads-list');
1978+
const emptyEl = document.getElementById('downloads-empty');
1979+
1980+
// Update badge
1981+
const activeCount = jobs.filter(j => j.status === 'downloading' || j.status === 'queued' || j.status === 'searching' || j.status === 'organizing' || j.status === 'importing').length;
1982+
const badge = document.getElementById('dl-badge');
1983+
if (activeCount > 0) {
1984+
badge.textContent = activeCount;
1985+
badge.classList.remove('hidden');
1986+
} else {
1987+
badge.classList.add('hidden');
18921988
}
1989+
1990+
if (jobs.length === 0) {
1991+
container.innerHTML = '';
1992+
emptyEl.classList.remove('hidden');
1993+
return;
1994+
}
1995+
1996+
emptyEl.classList.add('hidden');
1997+
container.innerHTML = jobs.map(renderDownloadJob).join('');
18931998
}
18941999

18952000
function renderDownloadJob(job) {
18962001
const st = STATUS_STYLES[job.status] || STATUS_STYLES.queued;
18972002
const progress = job.progress || 0;
18982003
const showProgress = job.status === 'downloading' && progress > 0;
2004+
const retryKey = String(job.job_id);
2005+
const retryPending = state.pendingRetryDownloads.has(retryKey);
18992006

19002007
let actions = '';
19012008
if (job.status === 'error' || job.status === 'dead_letter') {
1902-
actions = `<button onclick="retryDownload('${escapeHtml(job.job_id)}')" class="px-2.5 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors">${t('retry')}</button>`;
2009+
actions = `
2010+
<button
2011+
onclick="retryDownload('${escapeHtml(job.job_id)}')"
2012+
${retryPending ? 'disabled aria-busy="true"' : ''}
2013+
class="px-2.5 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-slate-300 rounded transition-colors flex items-center gap-1 disabled:opacity-100"
2014+
>
2015+
${retryPending ? '<svg class="w-3.5 h-3.5 spin" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle class="opacity-25" cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2"></circle><path class="opacity-90" fill="currentColor" d="M12 3a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 12 5.5V3z"></path></svg>' : ''}
2016+
<span>${retryPending ? t('loading') : t('retry')}</span>
2017+
</button>`;
19032018
}
19042019

19052020
return `
@@ -1927,22 +2042,45 @@ <h4 class="text-sm font-medium text-white truncate" title="${escapeHtml(job.titl
19272042
}
19282043

19292044
async function retryDownload(jobId) {
2045+
const key = String(jobId);
2046+
if (state.pendingRetryDownloads.has(key)) return;
2047+
2048+
state.pendingRetryDownloads.add(key);
2049+
renderDownloadList();
2050+
19302051
try {
19312052
await apiJson(`/api/downloads/jobs/${jobId}/retry`, { method: 'POST' });
19322053
showToast(t('retrying_download'), 'info');
1933-
refreshDownloads();
2054+
await refreshDownloads();
19342055
} catch (err) {
19352056
if (err.message !== 'Unauthorized') showToast(t('retry_failed'), 'error');
2057+
} finally {
2058+
state.pendingRetryDownloads.delete(key);
2059+
renderDownloadList();
19362060
}
19372061
}
19382062

19392063
async function clearCompleted() {
2064+
const button = document.getElementById('downloads-clear-btn');
2065+
const icon = document.getElementById('downloads-clear-icon');
2066+
if (button && icon) {
2067+
button.disabled = true;
2068+
button.setAttribute('aria-busy', 'true');
2069+
icon.classList.remove('hidden');
2070+
}
2071+
19402072
try {
19412073
await apiJson('/api/downloads/clear', { method: 'POST' });
19422074
showToast(t('cleared_completed'), 'success');
1943-
refreshDownloads();
2075+
await refreshDownloads();
19442076
} catch (err) {
19452077
if (err.message !== 'Unauthorized') showToast(t('failed_clear'), 'error');
2078+
} finally {
2079+
if (button && icon) {
2080+
button.disabled = false;
2081+
button.removeAttribute('aria-busy');
2082+
icon.classList.add('hidden');
2083+
}
19462084
}
19472085
}
19482086

@@ -2956,4 +3094,3 @@ <h4 class="text-sm font-medium text-white truncate">${escapeHtml(item.title || '
29563094
</script>
29573095
</body>
29583096
</html>
2959-

0 commit comments

Comments
 (0)