Initial commit: NAUT torrent web UI with stubbed server

Advanced torrent client web UI aimed at power users, with a
zero-dependency Node stub server (built-in http + SSE) serving live
mock data.

- Dense sortable/multi-select torrent grid with live updates
- Detail panel: General/Trackers/Peers/Content/Pieces (resizable)
- Sidebar filters: status, categories, tags, trackers
- Create/delete categories and tags (sidebar + right-click)
- Add via magnet or client-side-parsed .torrent upload
- RSS, integrated search, and read-only engine views
- Hand-drawn SVG icon set, dark theme, keyboard shortcuts

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ookami125 2026-06-16 21:15:31 -04:00
commit bc1be49a37
12 changed files with 2677 additions and 0 deletions

227
public/js/detail.js Normal file
View file

@ -0,0 +1,227 @@
// Detail panel: General / Trackers / Peers / Content (files) / Pieces.
import { api } from './api.js';
import * as f from './format.js';
const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
let activeTab = 'general';
let currentHash = null;
let refreshTimer = null;
let detailHeight = 300; // px, persisted across re-renders so resizing sticks
export function detailTab() { return activeTab; }
export function renderDetailShell(hash, host) {
currentHash = hash;
// Drive the container's grid track (persists across row clicks / re-renders),
// clamped so the panel always fits inside the available area.
const view = host.closest('.torrents-view');
if (view) {
detailHeight = clampDetailHeight(view, detailHeight);
view.style.setProperty('--detail-h', `${detailHeight}px`);
}
host.innerHTML = `
<div class="detail" id="detailPanel">
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
<div class="detail-tabs">
${TABS.map((t) => `<button class="dtab ${t === activeTab ? 'active' : ''}" data-dtab="${t}">${tabLabel(t)}</button>`).join('')}
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)"></button>
</div>
<div class="detail-body" id="detailBody"></div>
</div>`;
host.querySelectorAll('[data-dtab]').forEach((b) =>
b.addEventListener('click', () => { activeTab = b.dataset.dtab; renderDetailShell(currentHash, host); }));
setupResize(host);
loadTab();
}
function tabLabel(t) {
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
}
export function closeDetail() {
currentHash = null;
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = null;
}
async function loadTab() {
const body = document.getElementById('detailBody');
if (!body || !currentHash) return;
if (refreshTimer) clearInterval(refreshTimer);
const render = async () => {
if (!currentHash) return;
try {
if (activeTab === 'general') body.innerHTML = renderGeneral(await api.torrent(currentHash));
else if (activeTab === 'trackers') body.innerHTML = renderTrackers(await api.trackers(currentHash));
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
else if (activeTab === 'content') body.innerHTML = renderFiles(await api.files(currentHash));
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
} catch { /* ignore transient */ }
};
await render();
// live-refresh the dynamic tabs
if (['peers', 'pieces', 'general'].includes(activeTab)) refreshTimer = setInterval(render, 1500);
}
/* ---------- General ---------- */
function renderGeneral(t) {
const row = (k, v, mono) => `<div class="prop"><span class="k">${k}</span><span class="v ${mono ? 'mono' : ''}">${v}</span></div>`;
return `<div class="props">
<div class="section-h">Transfer</div>
${row('Status', `<span class="s-${t.state}">${f.stateLabel(t.state)}</span>`)}
${row('Progress', f.pct(t.progress))}
${row('Downloaded', f.bytes(t.downloaded))}
${row('Uploaded', f.bytes(t.uploaded))}
${row('Down speed', f.rate(t.dlspeed))}
${row('Up speed', f.rate(t.upspeed))}
${row('Share ratio', f.ratio(t.ratio))}
${row('ETA', f.eta(t.eta))}
${row('Availability', t.availability.toFixed(3))}
${row('Seeds', `${t.seeds} (${t.seedsTotal})`)}
${row('Peers', `${t.peers} (${t.peersTotal})`)}
${row('Down limit', t.downLimit ? f.rate(t.downLimit) : '∞')}
${row('Up limit', t.upLimit ? f.rate(t.upLimit) : '∞')}
${row('Ratio limit', t.ratioLimit > 0 ? t.ratioLimit.toFixed(2) : 'global')}
${row('Session DL / UL', `${f.bytes(t.downloadedSession)} / ${f.bytes(t.uploadedSession)}`)}
${row('Time active', f.duration(t.timeActive))}
<div class="section-h">Information</div>
${row('Name', f.esc(t.name), true)}
${row('Total size', f.bytes(t.size))}
${row('Pieces', `${t.pieceCount} × ${f.bytes(t.pieceSize)}`)}
${row('Save path', f.esc(t.savePath), true)}
${row('Content path', f.esc(t.contentPath), true)}
${row('Category', t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '—')}
${row('Tags', t.tags.length ? t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join(' ') : '—')}
${row('Added on', f.date(t.addedOn))}
${row('Completed on', f.date(t.completionOn))}
${row('Last activity', f.ago(t.lastActivity))}
${row('Hash (v1)', t.hash, true)}
${row('Privacy', t.private ? '<span class="priv-badge">PRIVATE</span>' : 'Public (DHT/PeX/LSD)')}
${row('Created by', f.esc(t.createdBy), true)}
${row('Creation date', f.date(t.creationDate))}
${row('Sequential', t.seqDl ? 'On' : 'Off')}
${row('Super seeding', t.superSeeding ? 'On' : 'Off')}
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
${row('Force start', t.forceStart ? 'On' : 'Off')}
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
</div>`;
}
/* ---------- Trackers ---------- */
function renderTrackers(trackers) {
return `<table class="dtbl"><thead><tr>
<th>Tier</th><th>URL</th><th>Status</th><th class="num">Seeds</th><th class="num">Peers</th>
<th class="num">Leeches</th><th class="num">Downloaded</th><th>Message</th>
</tr></thead><tbody>
${trackers.map((tr) => `<tr>
<td class="dim">${tr.tier < 0 ? '—' : tr.tier}</td>
<td>${f.esc(tr.url)}</td>
<td class="status-${tr.status.replace(/\s/g, '.')}">${tr.status}</td>
<td class="num">${tr.seeds < 0 ? '—' : tr.seeds}</td>
<td class="num">${tr.peers < 0 ? '—' : tr.peers}</td>
<td class="num">${tr.leeches < 0 ? '—' : tr.leeches}</td>
<td class="num">${tr.downloaded < 0 ? '—' : tr.downloaded}</td>
<td class="dim">${f.esc(tr.message)}</td>
</tr>`).join('')}
</tbody></table>`;
}
/* ---------- Peers ---------- */
function renderPeers(peers) {
if (!peers.length) return '<div class="empty">No peers connected</div>';
const sorted = [...peers].sort((a, b) => (b.dlspeed + b.upspeed) - (a.dlspeed + a.upspeed));
return `<table class="dtbl"><thead><tr>
<th>Country</th><th>IP : Port</th><th>Client</th><th>Conn</th><th>Flags</th>
<th class="num">Progress</th><th class="num">Down</th><th class="num">Up</th>
<th class="num">Downloaded</th><th class="num">Uploaded</th><th class="num">Rel.</th>
</tr></thead><tbody>
${sorted.map((p) => `<tr>
<td class="dim">${p.country}</td>
<td class="mono">${p.ip}:${p.port}</td>
<td>${f.esc(p.client)}</td>
<td class="dim">${p.connection}</td>
<td class="flagchip" title="D down U up O optimistic I incoming E encrypted X PEX H DHT">${p.flags || '·'}</td>
<td class="num">${f.pct(p.progress)}</td>
<td class="num" style="color:var(--dl)">${p.dlspeed ? f.rate(p.dlspeed) : ''}</td>
<td class="num" style="color:var(--up)">${p.upspeed ? f.rate(p.upspeed) : ''}</td>
<td class="num dim">${f.bytes(p.downloaded)}</td>
<td class="num dim">${f.bytes(p.uploaded)}</td>
<td class="num">${f.pct(p.relevance)}</td>
</tr>`).join('')}
</tbody></table>`;
}
/* ---------- Content / files ---------- */
function renderFiles(files) {
return `<table class="dtbl"><thead><tr>
<th>Name</th><th class="num">Size</th><th>Progress</th><th class="num">%</th>
<th>Priority</th><th class="num">Availability</th>
</tr></thead><tbody>
${files.map((file, i) => `<tr class="file-row">
<td>${f.esc(file.name)}</td>
<td class="num">${f.bytes(file.size)}</td>
<td><span class="mini-bar"><i style="width:${(file.progress * 100).toFixed(0)}%"></i></span></td>
<td class="num">${f.pct(file.progress)}</td>
<td><select class="prio-sel" data-file="${i}">
${[[0, 'Skip'], [1, 'Normal'], [6, 'High'], [7, 'Max']].map(([v, l]) =>
`<option value="${v}" ${file.priority === v ? 'selected' : ''}>${l}</option>`).join('')}
</select></td>
<td class="num">${file.availability.toFixed(2)}</td>
</tr>`).join('')}
</tbody></table>`;
}
/* ---------- Pieces ---------- */
function renderPieces(data) {
const { pieces, pieceSize, pieceCount } = data;
let done = 0, dl = 0;
for (const p of pieces) { if (p === 2) done++; else if (p === 1) dl++; }
const cells = pieces.map((p) => `<i class="piece ${p === 2 ? 'done' : p === 1 ? 'dl' : ''}"></i>`).join('');
return `
<div class="piece-legend">
<span><i class="swatch" style="background:var(--dl)"></i> Have (${done})</span>
<span><i class="swatch" style="background:var(--warn)"></i> Downloading (${dl})</span>
<span><i class="swatch" style="background:var(--bg-3)"></i> Missing (${pieceCount - done - dl})</span>
<span class="dim">${pieceCount} pieces · ${f.bytes(pieceSize)} each</span>
</div>
<div class="piecemap">${cells}</div>`;
}
/* ---------- resize ---------- */
// Keep the panel between 120px and (container height 80px) so the list above
// always keeps a usable strip and the panel never overflows/clips.
function clampDetailHeight(view, h) {
const maxH = Math.max(120, view.clientHeight - 80);
return Math.round(Math.max(120, Math.min(maxH, h)));
}
function setupResize(host) {
const handle = host.querySelector('#detailResize');
const view = host.closest('.torrents-view');
if (!handle || !view) return;
handle.addEventListener('pointerdown', (e) => {
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const startY = e.clientY;
const startH = detailHeight;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'ns-resize';
const move = (ev) => {
detailHeight = clampDetailHeight(view, startH + (startY - ev.clientY));
view.style.setProperty('--detail-h', `${detailHeight}px`);
};
const up = () => {
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
});
}