Files
bitvid/torrent/ui/torrentTable.js
T

267 lines
7.0 KiB
JavaScript

const COLUMN_DEFS = [
{
key: "name",
label: "Name",
headerClass: "w-full sm:w-1/3 text-left",
cellClass: "font-medium text-text truncate",
},
{
key: "progress",
label: "Progress",
headerClass: "w-24 text-right",
cellClass: "tabular-nums text-right",
formatter: "progress",
},
{
key: "downloadSpeed",
label: "\u2193 Speed",
headerClass: "w-24 text-right",
cellClass: "tabular-nums text-right",
formatter: "bytesPerSecond",
},
{
key: "uploadSpeed",
label: "\u2191 Speed",
headerClass: "w-24 text-right",
cellClass: "tabular-nums text-right",
formatter: "bytesPerSecond",
},
{
key: "numPeers",
label: "Peers",
headerClass: "w-20 text-right",
cellClass: "tabular-nums text-right",
formatter: "integer",
},
{
key: "ratio",
label: "Ratio",
headerClass: "w-20 text-right",
cellClass: "tabular-nums text-right",
formatter: "ratio",
},
];
function getRowId(torrent) {
if (!torrent) {
return "";
}
return torrent.infoHash || torrent.magnetURI || torrent.name || String(Math.random());
}
function isSelectedTorrent(selected, torrent) {
if (!selected || !torrent) {
return false;
}
return (
selected === torrent ||
(selected.magnetURI && selected.magnetURI === torrent.magnetURI) ||
(selected.infoHash && selected.infoHash === torrent.infoHash)
);
}
function formatProgress(value) {
if (typeof value !== "number" || Number.isNaN(value)) {
return "";
}
return `${(value * 100).toFixed(1)}%`;
}
function formatRatio(value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "0.00";
}
return value.toFixed(2);
}
function formatInteger(value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return "0";
}
return Math.max(0, Math.floor(value)).toString();
}
function formatBytesPerSecond(value, formatter) {
if (typeof formatter === "function") {
return formatter(value, true) || "";
}
if (typeof value !== "number" || Number.isNaN(value) || value < 1) {
return "";
}
const units = ["B", "kB", "MB", "GB", "TB"];
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1000)), units.length - 1);
const val = (value / Math.pow(1000, exponent)).toFixed(1);
return `${val} ${units[exponent]}/s`;
}
function resolveFormatter(column, customFormatters) {
const name = column.formatter;
if (typeof name === "function") {
return name;
}
const userFormatter = customFormatters?.[name];
if (typeof userFormatter === "function") {
return userFormatter;
}
switch (name) {
case "progress":
return formatProgress;
case "ratio":
return formatRatio;
case "integer":
return formatInteger;
case "bytesPerSecond":
return (value) => formatBytesPerSecond(value, customFormatters?.bytes);
default:
return (value) => value;
}
}
export function createTorrentTable({
documentRef = typeof document !== "undefined" ? document : null,
root,
onSelect,
formatters = {},
} = {}) {
if (!documentRef) {
throw new Error("createTorrentTable requires a document reference");
}
if (!(root instanceof documentRef.defaultView.HTMLElement)) {
throw new Error("createTorrentTable requires a valid root element");
}
const doc = documentRef;
root.innerHTML = "";
const container = doc.createElement("div");
container.className = "overflow-hidden rounded-xl border border-border-translucent bg-surface shadow-sm";
const scroll = doc.createElement("div");
scroll.className = "overflow-x-auto";
container.appendChild(scroll);
const table = doc.createElement("table");
table.className = "min-w-full divide-y divide-border-translucent text-sm";
scroll.appendChild(table);
const thead = doc.createElement("thead");
thead.className = "bg-surface-alt text-muted-strong";
const headerRow = doc.createElement("tr");
COLUMN_DEFS.forEach((column) => {
const th = doc.createElement("th");
th.setAttribute("scope", "col");
th.className = "px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide";
if (column.headerClass) {
th.className += ` ${column.headerClass}`;
}
th.textContent = column.label;
headerRow.appendChild(th);
});
thead.appendChild(headerRow);
table.appendChild(thead);
const tbody = doc.createElement("tbody");
tbody.className = "divide-y divide-border-translucent";
table.appendChild(tbody);
const emptyBody = doc.createElement("tbody");
const emptyRow = doc.createElement("tr");
const emptyCell = doc.createElement("td");
emptyCell.className = "px-4 py-8 text-center text-sm text-muted";
emptyCell.colSpan = COLUMN_DEFS.length;
emptyCell.textContent = "No active torrents yet. Add a magnet link to get started.";
emptyRow.appendChild(emptyCell);
emptyBody.appendChild(emptyRow);
table.appendChild(emptyBody);
root.appendChild(container);
function render(torrents = [], selectedTorrent = null) {
const rows = Array.isArray(torrents) ? torrents : [];
tbody.innerHTML = "";
if (!rows.length) {
tbody.hidden = true;
emptyBody.hidden = false;
return;
}
tbody.hidden = false;
emptyBody.hidden = true;
for (const torrent of rows) {
const row = doc.createElement("tr");
row.className = [
"group",
"align-middle",
"cursor-pointer",
"transition",
"hover:bg-panel-hover",
"focus-visible:outline",
"focus-visible:outline-2",
"focus-visible:outline-offset-2",
"focus-visible:outline-info",
].join(" ");
row.setAttribute("role", "button");
row.setAttribute("tabindex", "0");
row.dataset.torrentId = getRowId(torrent);
if (isSelectedTorrent(selectedTorrent, torrent)) {
row.classList.add("bg-info/10", "text-text");
row.setAttribute("aria-pressed", "true");
} else {
row.setAttribute("aria-pressed", "false");
}
row.addEventListener("click", () => {
if (typeof onSelect === "function") {
onSelect(torrent);
}
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (typeof onSelect === "function") {
onSelect(torrent);
}
}
});
COLUMN_DEFS.forEach((column) => {
const td = doc.createElement("td");
td.className = "px-4 py-3";
if (column.cellClass) {
td.className += ` ${column.cellClass}`;
}
const value = torrent ? torrent[column.key] : undefined;
const formatter = resolveFormatter(column, formatters);
const displayValue = formatter(value, torrent, column);
const span = doc.createElement("span");
span.textContent = displayValue == null ? "" : String(displayValue);
td.appendChild(span);
row.appendChild(td);
});
tbody.appendChild(row);
}
}
function destroy() {
root.innerHTML = "";
}
return { render, destroy };
}