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 = `
+
`;
+ 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 = `
- `;
+
+ ${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);
}));