Wire up controls that were rendered but inert, expose unused server actions, and harden the live update path. Controls now functional: - File priority dropdowns (Content tab) now persist via new setFilePriority server action - Context menu: Set location, Limit download/upload rate, Set share limit (wire setSavePath/setDownLimit/setUpLimit/setRatioLimit) - Trackers sidebar filter actually filters (snapshot now carries per-torrent trackerHosts) Robustness: - Grid updates cells in place when row order/columns are unchanged (preserves text selection/scroll, no per-second listener re-bind) - Boot retries until the server responds; SSE drops show a "Reconnecting" banner; failed actions/add/delete surface a toast - Prune selection/detail for torrents that disappear - Drop Backspace-as-delete (kept Delete) Cleanup: - Remove pseudo DHT/PeX/LSD names from the real-tracker host pool in mock data (they leaked into announce URLs and the sidebar) - Remove dead detailTab import/export Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
10 KiB
JavaScript
232 lines
10 KiB
JavaScript
// 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 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)); bindFilePriorities(body); }
|
||
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>`;
|
||
}
|
||
|
||
function bindFilePriorities(body) {
|
||
const hash = currentHash;
|
||
body.querySelectorAll('.prio-sel').forEach((sel) =>
|
||
sel.addEventListener('change', () =>
|
||
api.action('setFilePriority', [hash], { index: +sel.dataset.file, priority: +sel.value })));
|
||
}
|
||
|
||
/* ---------- 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);
|
||
});
|
||
}
|