// state.js — minimal pub/sub store + hash router. Vanilla, no framework. const listeners = new Set(); const state = { remotes: [], // [{ name, type }] jobs: new Map(), // jobid -> { jobid, status, progress, action, src, dst } providers: null, // cached /config/providers response }; export function getState() { return state; } export function setState(patch) { Object.assign(state, patch); for (const l of listeners) { try { l(state); } catch (e) { console.error("state listener error", e); } } } export function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } // --- Router --- // Hash-based routes: // #/remotes // #/browse// // #/jobs // #/jobs/new // #/configure/new // #/configure/new/ // #/configure/edit/ const routeListeners = new Set(); export function onRoute(fn) { routeListeners.add(fn); return () => routeListeners.delete(fn); } function parseHash(hash) { const raw = hash.replace(/^#\/?/, ""); const parts = raw.split("/").filter(Boolean); if (parts.length === 0) { return { name: "remotes", params: {} }; } switch (parts[0]) { case "remotes": return { name: "remotes", params: {} }; case "browse": return { name: "browse", params: { remote: decodeURIComponent(parts[1] || ""), path: parts.slice(2).map(decodeURIComponent).join("/"), }, }; case "jobs": if (parts[1] === "new") { return { name: "jobs-new", params: {} }; } return { name: "jobs", params: {} }; case "configure": if (parts[1] === "new") { return { name: "configure-new", params: { provider: parts[2] ? decodeURIComponent(parts[2]) : "" }, }; } if (parts[1] === "edit") { return { name: "configure-edit", params: { remote: decodeURIComponent(parts[2] || "") }, }; } return { name: "configure-new", params: { provider: "" } }; default: return { name: "remotes", params: {} }; } } export function navigate(hash) { if (location.hash !== hash) { location.hash = hash; } else { dispatch(); } } function dispatch() { const route = parseHash(location.hash); for (const l of routeListeners) { try { l(route); } catch (e) { console.error("route listener error", e); } } } window.addEventListener("hashchange", dispatch); window.addEventListener("load", dispatch); // --- Toast --- export function toast(message, kind = "default", ttl = 4000) { const stack = document.getElementById("toast-stack"); if (!stack) return; const el = document.createElement("div"); el.className = `toast toast-${kind}`; el.textContent = message; stack.appendChild(el); setTimeout(() => el.remove(), ttl); } // --- Format helpers --- export function formatBytes(n) { if (n == null || isNaN(n)) return "—"; if (n < 1024) return `${n} B`; const units = ["KB", "MB", "GB", "TB", "PB"]; let v = n / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(1)} ${units[i]}`; } export function formatSpeed(bytesPerSec) { return formatBytes(bytesPerSec) + "/s"; } export function formatDuration(seconds) { if (seconds == null || !isFinite(seconds)) return "—"; const s = Math.round(seconds); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${sec}s`; return `${sec}s`; } export function formatTime(iso) { if (!iso) return "—"; try { const d = new Date(iso); return d.toLocaleString(); } catch { return iso; } }