Naut-Plugin-WebUI/public/js/detail.js
ookami125 bc1be49a37 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>
2026-06-16 21:15:31 -04:00

227 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
});
}