// Same origin as the page (works for LAN IP / hostname). Avoid relative issues // when the app is served with a non-root base path later. const API_BASE = window.location.origin; let queuePoll = null; async function apiFetch(path, opts) { const res = await fetch(`${API_BASE}${path}`, { ...opts, headers: { Accept: 'application/json', ...(opts && opts.headers), }, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(text || `HTTP ${res.status}`); } const ct = res.headers.get('content-type') || ''; if (ct.includes('application/json')) return res.json(); return res.text(); } // ── Tabs ──────────────────────────────────────────────────────────────────── function startQueuePoll() { stopQueuePoll(); queuePoll = setInterval(renderQueue, 2000); } function stopQueuePoll() { if (queuePoll) { clearInterval(queuePoll); queuePoll = null; } } function switchTab(name) { document.querySelectorAll('.tab').forEach(t => { const on = t.dataset.tab === name; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); }); document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); document.getElementById('tab-' + name).classList.add('active'); if (name === 'queue') { renderQueue(); startQueuePoll(); } else stopQueuePoll(); } // TUI-ish keys: 1/2 switch tabs, / focuses search document.addEventListener('keydown', (e) => { if (e.target.matches('input, textarea')) return; if (e.key === '1') switchTab('search'); else if (e.key === '2') switchTab('queue'); else if (e.key === '/') { e.preventDefault(); switchTab('search'); searchInput.focus(); } }); document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => switchTab(tab.dataset.tab)); }); // ── Search ────────────────────────────────────────────────────────────────── const searchInput = document.getElementById('searchInput'); const searchBtn = document.getElementById('searchBtn'); const searchResults = document.getElementById('searchResults'); async function doSearch() { const q = searchInput.value.trim(); if (!q) return; searchResults.innerHTML = '
Searching...
'; try { const data = await apiFetch(`/api/search?q=${encodeURIComponent(q)}&limit=30`); renderSearchResults(data); } catch (err) { searchResults.innerHTML = `
Search failed: ${err.message}
`; } } searchBtn.addEventListener('click', doSearch); searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); function formatSize(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; } function renderSearchResults(files) { if (!files || files.length === 0) { searchResults.innerHTML = '
No results found.
'; return; } searchResults.innerHTML = files.map(f => { const added = Boolean(addedIdents[f.ident]); return `
${escapeHtml(f.name)}
${formatSize(f.size)} · votes: ${f.votes}
`; }).join(''); searchResults.querySelectorAll('button:not(.added)').forEach(btn => { btn.addEventListener('click', async () => { const ident = btn.dataset.ident; const name = btn.dataset.name; const size = parseInt(btn.dataset.size); btn.disabled = true; btn.textContent = '[…]'; try { await addDownload(ident, name, size); btn.classList.add('added'); btn.textContent = '[+]'; } catch (err) { btn.textContent = '[err]'; setTimeout(() => { btn.disabled = false; btn.textContent = '[add]'; }, 2000); } }); }); } // ── Add download via new API route ────────────────────────────────────────── const addedIdents = {}; async function addDownload(ident, name, size) { await apiFetch('/api/queue/add', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ident, name, size }), }); addedIdents[ident] = true; } // ── Queue ─────────────────────────────────────────────────────────────────── const queueList = document.getElementById('queueList'); async function fetchQueue() { return apiFetch('/api/queue'); } function renderQueue() { fetchQueue().then(entries => { if (!entries || entries.length === 0) { queueList.innerHTML = '
Queue is empty.
'; return; } queueList.innerHTML = entries.map(e => { const pct = Math.round((e.progress || 0) * 100); const downloaded = formatSize(e.progress * e.size); const total = formatSize(e.size); let fillClass = ''; if (e.state === 'uploading') fillClass = 'done'; else if (e.state === 'error') fillClass = 'error'; else if (e.state === 'queuedDL') fillClass = 'queued'; return `
${e.state} ${escapeHtml(e.name)}
${downloaded} / ${total} ${pct}%
${e.speed || ''}
`; }).join(''); // Attach cancel handlers queueList.querySelectorAll('.cancel-btn').forEach(btn => { btn.addEventListener('click', async () => { const hash = btn.dataset.hash; btn.disabled = true; btn.textContent = '[…]'; try { await apiFetch('/api/queue/cancel', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hash }), }); } catch {} renderQueue(); }); }); }).catch(err => { queueList.innerHTML = `
Failed to load queue: ${err.message}
`; }); } // On load, switch to queue tab if there are active downloads fetchQueue().then(entries => { if (entries && entries.length > 0) switchTab('queue'); }); // Stop polling when tab hidden document.addEventListener('visibilitychange', () => { if (document.hidden) stopQueuePoll(); else { const active = document.querySelector('.tab.active'); if (active && active.dataset.tab === 'queue') startQueuePoll(); } }); // ── Helpers ───────────────────────────────────────────────────────────────── function escapeHtml(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } function escapeAttr(str) { return str.replace(/"/g, '"').replace(/'/g, '''); }