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>
87 lines
3.1 KiB
JavaScript
87 lines
3.1 KiB
JavaScript
// Display formatters — units, durations, dates, states.
|
||
|
||
export function bytes(n, dp = 1) {
|
||
if (n == null || isNaN(n)) return '–';
|
||
if (n === 0) return '0 B';
|
||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||
return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : dp)} ${u[i]}`;
|
||
}
|
||
|
||
export function rate(n) {
|
||
if (!n) return '–';
|
||
return `${bytes(n)}/s`;
|
||
}
|
||
|
||
export function eta(sec) {
|
||
if (sec == null || sec >= 8640000 || sec < 0) return '∞';
|
||
if (sec < 1) return '0s';
|
||
const d = Math.floor(sec / 86400);
|
||
const h = Math.floor((sec % 86400) / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
const s = Math.floor(sec % 60);
|
||
if (d) return `${d}d ${h}h`;
|
||
if (h) return `${h}h ${m}m`;
|
||
if (m) return `${m}m ${s}s`;
|
||
return `${s}s`;
|
||
}
|
||
|
||
export function duration(sec) {
|
||
if (sec == null || sec < 0) return '–';
|
||
const d = Math.floor(sec / 86400);
|
||
const h = Math.floor((sec % 86400) / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
if (d) return `${d}d ${h}h ${m}m`;
|
||
if (h) return `${h}h ${m}m`;
|
||
return `${m}m`;
|
||
}
|
||
|
||
export function pct(p) { return `${(p * 100).toFixed(1)}%`; }
|
||
|
||
export function ratio(r) {
|
||
if (r == null) return '–';
|
||
if (r >= 9999) return '∞';
|
||
return r.toFixed(2);
|
||
}
|
||
|
||
export function date(ms) {
|
||
if (!ms || ms < 0) return '–';
|
||
const d = new Date(ms);
|
||
return d.toLocaleString(undefined, { year: '2-digit', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
export function ago(ms) {
|
||
if (!ms || ms < 0) return '–';
|
||
const s = (Date.now() - ms) / 1000;
|
||
if (s < 60) return 'just now';
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||
return `${Math.floor(s / 86400)}d ago`;
|
||
}
|
||
|
||
const STATE_LABEL = {
|
||
downloading: 'Downloading', forcedDL: '[F] Downloading', metaDL: 'Fetching metadata',
|
||
stalledDL: 'Stalled (DL)', uploading: 'Seeding', forcedUP: '[F] Seeding',
|
||
stalledUP: 'Seeding (idle)', pausedDL: 'Paused', pausedUP: 'Completed',
|
||
checkingDL: 'Checking', checkingUP: 'Checking', moving: 'Moving',
|
||
queuedDL: 'Queued', queuedUP: 'Queued', error: 'Error', missingFiles: 'Missing files',
|
||
};
|
||
export function stateLabel(s) { return STATE_LABEL[s] || s; }
|
||
|
||
const STATE_COLOR = {
|
||
downloading: 'var(--dl)', forcedDL: 'var(--dl)', metaDL: 'var(--dl)',
|
||
uploading: 'var(--up)', forcedUP: 'var(--up)',
|
||
stalledDL: 'var(--txt-dim)', stalledUP: 'var(--txt-dim)',
|
||
pausedDL: 'var(--pause)', pausedUP: 'var(--pause)',
|
||
checkingDL: 'var(--check)', checkingUP: 'var(--check)', moving: 'var(--check)',
|
||
queuedDL: 'var(--queue)', queuedUP: 'var(--queue)',
|
||
error: 'var(--err)', missingFiles: 'var(--err)',
|
||
};
|
||
export function stateColor(s) { return STATE_COLOR[s] || 'var(--txt-dim)'; }
|
||
|
||
const PRIO_LABEL = { 0: 'Do not download', 1: 'Normal', 6: 'High', 7: 'Maximum' };
|
||
export function filePrio(p) { return PRIO_LABEL[p] ?? 'Normal'; }
|
||
|
||
export function esc(s) {
|
||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||
}
|