diff --git a/public/js/app.js b/public/js/app.js index 002c02c..fdcf295 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -5,6 +5,14 @@ import { api } from './api.js'; import * as f from './format.js'; import { renderDetailShell, closeDetail } from './detail.js'; +import { + getPluginView, + loadPlugins, + mountPluginSidebarSections, + pluginViews, + renderPluginSidebarSections, + renderPluginView, +} from './plugins.js'; /* ===================== state ===================== */ const state = { @@ -56,14 +64,34 @@ const STATUS_FILTERS = [ /* ===================== bootstrap ===================== */ async function boot() { - bindGlobal(); + await ensureLogin(); await loadMeta(); // retries until the server answers + await loadPlugins(api, appContext()); + renderPluginViewTabs(); + bindGlobal(); syncAltToggle(); renderSidebar(); renderView(); api.stream(onSnapshot, onStatus); } +function appContext() { + return { + api, + f, + state, + toast, + refreshMeta, + setView: switchViewTab, + renderView, + selectedHashes: () => [...state.selected], + selectHashes: (hashes) => { + state.selected = new Set(hashes || []); + renderGrid(); + }, + }; +} + async function loadMeta() { for (;;) { try { state.meta = await api.meta(); return; } @@ -71,6 +99,51 @@ async function loadMeta() { } } +async function ensureLogin() { + const auth = await api.authStatus(); + if (auth.authenticated) return; + document.getElementById('app').hidden = true; + await new Promise((resolve) => showLogin(auth, resolve)); + document.getElementById('app').hidden = false; +} + +function showLogin(auth, done) { + const host = document.createElement('div'); + host.className = 'login-screen'; + host.innerHTML = ` +
+
+ + NAUT + torrent console +
+
+
+ + + ${auth.generatedPassword ? '

A password was generated for this server process. Check the server console output.

' : ''} +
`; + document.body.appendChild(host); + const form = host.querySelector('#loginForm'); + const err = host.querySelector('#loginError'); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + err.hidden = true; + form.querySelector('.login-submit').disabled = true; + try { + await api.login(host.querySelector('#loginUser').value.trim(), host.querySelector('#loginPass').value); + host.remove(); + done(); + } catch { + err.textContent = 'Invalid username or password'; + err.hidden = false; + form.querySelector('.login-submit').disabled = false; + host.querySelector('#loginPass').select(); + } + }); + setTimeout(() => host.querySelector('#loginPass')?.focus(), 0); +} + function onSnapshot(snap) { state.snapshot = snap; // drop selection / detail for torrents that no longer exist @@ -157,12 +230,14 @@ function renderSidebar() { const trackers = state.meta.trackers.slice(0, 10) .map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join(''); + const pluginSections = renderPluginSidebarSections(appContext()); el.innerHTML = `
Status
${statusItems}
Categories
${cats}
Tags
${tags || '
none
'}
-
Trackers
${trackers}
`; +
Trackers
${trackers}
+ ${pluginSections}`; el.querySelectorAll('.side-item[data-type]').forEach((it) => { it.addEventListener('click', () => { @@ -178,6 +253,7 @@ function renderSidebar() { el.querySelectorAll('.side-add[data-add]').forEach((b) => b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); })); + mountPluginSidebarSections(el, appContext()); } function onSidebarContext(e, type, value, removable) { @@ -210,6 +286,7 @@ function renderView() { if (state.view === 'rss') return renderRssView(host); if (state.view === 'search') return renderSearchView(host); if (state.view === 'settings') return renderSettingsView(host); + if (getPluginView(state.view)) return renderPluginView(state.view, host, appContext()); } /* ---------- torrents view ---------- */ @@ -528,10 +605,22 @@ function bdecode(buf) { return parse(); } +function bytesToBase64(bytes) { + let bin = ''; + const chunk = 0x8000; // avoid arg-count limits on String.fromCharCode + for (let i = 0; i < bytes.length; i += chunk) + bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); + return btoa(bin); +} + async function readTorrentFile(file) { const td = new TextDecoder('utf-8'); + const bytes = new Uint8Array(await file.arrayBuffer()); + // The raw .torrent bytes are what the server actually adds; the parsed + // fields below are only for the modal's preview readout. + const data = bytesToBase64(bytes); try { - const meta = bdecode(new Uint8Array(await file.arrayBuffer())); + const meta = bdecode(bytes); const info = meta.info; const name = td.decode(info.name); const pieceSize = info['piece length']; @@ -542,10 +631,10 @@ async function readTorrentFile(file) { files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length })); size = files.reduce((a, fl) => a + fl.size, 0); } - return { name, size, pieceSize, pieceCount, files }; + return { name, size, pieceSize, pieceCount, files, data }; } catch { - // Not parseable as bencode — fall back to the filename. - return { name: file.name.replace(/\.torrent$/i, '') }; + // Not parseable as bencode — still upload the bytes; the server validates. + return { name: file.name.replace(/\.torrent$/i, ''), data }; } } @@ -880,6 +969,18 @@ function openModal(title, bodyHtml, buttons) { function closeModal() { document.getElementById('modalBackdrop').hidden = true; } /* ===================== view tabs ===================== */ +function renderPluginViewTabs() { + const nav = document.getElementById('viewtabs'); + for (const view of pluginViews()) { + if (nav.querySelector(`[data-view="${CSS.escape(view.id)}"]`)) continue; + const button = document.createElement('button'); + button.className = 'vtab plugin-vtab'; + button.dataset.view = view.id; + button.textContent = view.label; + nav.appendChild(button); + } +} + function switchViewTab(view) { state.view = view; document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view)); @@ -891,6 +992,7 @@ function bindGlobal() { document.querySelectorAll('.toolbar [data-act]').forEach((b) => b.addEventListener('click', () => { const a = b.dataset.act; + if (a === 'logout') return guard(async () => { await api.logout(); location.reload(); }, 'Failed to sign out'); if (a === 'add') return openAddModal(); doAction(a); }));