Initial DDC release
This commit is contained in:
commit
434f6441da
57 files changed
+4606
No files matched your search
Executable
+61
@@ -0,0 +1,61 @@
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const unauthorizedListeners = new Set();
|
||||
|
||||
export function onUnauthorized(listener) {
|
||||
unauthorizedListeners.add(listener);
|
||||
}
|
||||
|
||||
function parseBody(text) {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function request(method, path, body) {
|
||||
const options = { method, credentials: "same-origin", headers: {} };
|
||||
if (body !== undefined) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(path, options);
|
||||
const data = parseBody(await response.text());
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && path !== "/api/login") {
|
||||
unauthorizedListeners.forEach((listener) => listener());
|
||||
}
|
||||
const message = data && data.error ? data.error : `Request failed (${response.status})`;
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function jobPath(id, suffix = "") {
|
||||
return `/api/jobs/${encodeURIComponent(id)}${suffix}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request("GET", "/api/session"),
|
||||
login: (username, password) => request("POST", "/api/login", { username, password }),
|
||||
logout: () => request("POST", "/api/logout", {}),
|
||||
libraries: () => request("GET", "/api/libraries"),
|
||||
disk: () => request("GET", "/api/disk"),
|
||||
settings: () => request("GET", "/api/settings"),
|
||||
saveSettings: (settings) => request("POST", "/api/settings", settings),
|
||||
jobs: () => request("GET", "/api/jobs"),
|
||||
createJob: (url, autoExtract) => request("POST", "/api/jobs", { url, autoExtract }),
|
||||
files: (id) => request("GET", jobPath(id, "/files")),
|
||||
extract: (id, path) => request("POST", jobPath(id, "/extract"), { path }),
|
||||
move: (id, payload) => request("POST", jobPath(id, "/move"), payload),
|
||||
remove: (id) => request("DELETE", jobPath(id)),
|
||||
};
|
||||
Executable
+293
@@ -0,0 +1,293 @@
|
||||
import { api, onUnauthorized } from "./api.js";
|
||||
import { element } from "./dom.js";
|
||||
import { formatBytes } from "./format.js";
|
||||
import { isActive, renderJobs, updateSpeeds } from "./jobs.js";
|
||||
import { setupLogin } from "./login.js";
|
||||
import { loadSettings, openSettings, setupSettings } from "./settings.js";
|
||||
import { renderTree, topLevelSelection } from "./tree.js";
|
||||
|
||||
const pollIntervalMs = 1500;
|
||||
|
||||
const elements = {
|
||||
loginView: document.getElementById("login-view"),
|
||||
loginForm: document.getElementById("login-form"),
|
||||
loginError: document.getElementById("login-error"),
|
||||
appView: document.getElementById("app-view"),
|
||||
logoutButton: document.getElementById("logout-button"),
|
||||
settingsButton: document.getElementById("settings-button"),
|
||||
diskStatus: document.getElementById("disk-status"),
|
||||
downloadForm: document.getElementById("download-form"),
|
||||
downloadUrl: document.getElementById("download-url"),
|
||||
autoExtract: document.getElementById("auto-extract"),
|
||||
downloadError: document.getElementById("download-error"),
|
||||
jobList: document.getElementById("job-list"),
|
||||
jobEmpty: document.getElementById("job-empty"),
|
||||
detail: document.getElementById("job-detail"),
|
||||
detailTitle: document.getElementById("detail-title"),
|
||||
closeDetailButton: document.getElementById("close-detail-button"),
|
||||
fileTree: document.getElementById("file-tree"),
|
||||
selectionSummary: document.getElementById("selection-summary"),
|
||||
librarySelect: document.getElementById("library-select"),
|
||||
subfolderInput: document.getElementById("subfolder-input"),
|
||||
cleanupCheckbox: document.getElementById("cleanup-checkbox"),
|
||||
moveButton: document.getElementById("move-button"),
|
||||
actionError: document.getElementById("action-error"),
|
||||
};
|
||||
|
||||
const state = {
|
||||
jobs: [],
|
||||
selectedJobId: null,
|
||||
treeStatus: null,
|
||||
selection: new Set(),
|
||||
pollTimer: null,
|
||||
refreshing: false,
|
||||
};
|
||||
|
||||
function showLogin() {
|
||||
stopPolling();
|
||||
closeDetail();
|
||||
elements.appView.hidden = true;
|
||||
elements.loginView.hidden = false;
|
||||
}
|
||||
|
||||
async function showApp() {
|
||||
elements.loginView.hidden = true;
|
||||
elements.appView.hidden = false;
|
||||
applyDefaults(await loadSettings());
|
||||
await loadLibraries();
|
||||
await refresh();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function applyDefaults(settings) {
|
||||
elements.autoExtract.checked = settings.autoExtract;
|
||||
elements.cleanupCheckbox.checked = settings.cleanupAfterMove;
|
||||
}
|
||||
|
||||
async function showSettings() {
|
||||
try {
|
||||
await openSettings();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
state.pollTimer = window.setInterval(refresh, pollIntervalMs);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (state.pollTimer !== null) window.clearInterval(state.pollTimer);
|
||||
state.pollTimer = null;
|
||||
}
|
||||
|
||||
async function loadLibraries() {
|
||||
const destinations = (await api.libraries()) || [];
|
||||
const previousValue = elements.librarySelect.value;
|
||||
const groups = new Map();
|
||||
|
||||
for (const destination of destinations) {
|
||||
if (!groups.has(destination.root)) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = destination.root;
|
||||
groups.set(destination.root, group);
|
||||
}
|
||||
const option = element("option", "", destination.name);
|
||||
option.value = destination.path;
|
||||
option.title = destination.path;
|
||||
groups.get(destination.root).append(option);
|
||||
}
|
||||
|
||||
elements.librarySelect.replaceChildren(...groups.values());
|
||||
if (destinations.some((destination) => destination.path === previousValue)) {
|
||||
elements.librarySelect.value = previousValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (state.refreshing) return;
|
||||
state.refreshing = true;
|
||||
try {
|
||||
const [jobs, disk] = await Promise.all([api.jobs(), api.disk()]);
|
||||
state.jobs = jobs || [];
|
||||
updateSpeeds(state.jobs);
|
||||
elements.diskStatus.textContent = `${formatBytes(disk.availableBytes)} free`;
|
||||
renderJobList();
|
||||
await syncDetail();
|
||||
} catch (error) {
|
||||
if (error.status !== 401) console.error(error);
|
||||
} finally {
|
||||
state.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderJobList() {
|
||||
elements.jobEmpty.hidden = state.jobs.length > 0;
|
||||
renderJobs(elements.jobList, state.jobs, state.selectedJobId, {
|
||||
onBrowse: openDetail,
|
||||
onDelete: deleteJob,
|
||||
});
|
||||
}
|
||||
|
||||
function selectedJob() {
|
||||
return state.jobs.find((job) => job.id === state.selectedJobId);
|
||||
}
|
||||
|
||||
function openDetail(job) {
|
||||
state.selectedJobId = job.id;
|
||||
state.treeStatus = null;
|
||||
state.selection.clear();
|
||||
elements.actionError.textContent = "";
|
||||
elements.detail.hidden = false;
|
||||
renderJobList();
|
||||
syncDetail();
|
||||
loadLibraries().catch((error) => {
|
||||
elements.actionError.textContent = error.message;
|
||||
});
|
||||
elements.detail.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
state.selectedJobId = null;
|
||||
state.treeStatus = null;
|
||||
state.selection.clear();
|
||||
elements.detail.hidden = true;
|
||||
elements.fileTree.replaceChildren();
|
||||
}
|
||||
|
||||
async function syncDetail() {
|
||||
const job = selectedJob();
|
||||
if (!job) {
|
||||
if (state.selectedJobId) closeDetail();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.detailTitle.textContent = job.fileName;
|
||||
if (job.status === state.treeStatus) return;
|
||||
state.treeStatus = job.status;
|
||||
|
||||
if (job.status === "ready") {
|
||||
await loadTree(job.id);
|
||||
return;
|
||||
}
|
||||
|
||||
state.selection.clear();
|
||||
updateSelectionSummary();
|
||||
const text = job.status === "completed"
|
||||
? "Files were moved and temporary files were removed."
|
||||
: "Files will be available when this download is ready.";
|
||||
elements.fileTree.replaceChildren(element("p", "muted", text));
|
||||
}
|
||||
|
||||
async function loadTree(jobId) {
|
||||
try {
|
||||
const nodes = (await api.files(jobId)) || [];
|
||||
state.selection.clear();
|
||||
renderTree(elements.fileTree, nodes, state.selection, {
|
||||
onSelectionChange: updateSelectionSummary,
|
||||
onExtract: extractArchive,
|
||||
});
|
||||
updateSelectionSummary();
|
||||
} catch (error) {
|
||||
elements.actionError.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectionSummary() {
|
||||
const count = topLevelSelection(state.selection).length;
|
||||
const job = selectedJob();
|
||||
elements.selectionSummary.textContent = count === 0 ? "Nothing selected" : `${count} item(s) selected`;
|
||||
elements.moveButton.disabled = count === 0 || !job || job.status !== "ready";
|
||||
}
|
||||
|
||||
async function submitDownload(event) {
|
||||
event.preventDefault();
|
||||
elements.downloadError.textContent = "";
|
||||
try {
|
||||
await api.createJob(elements.downloadUrl.value.trim(), elements.autoExtract.checked);
|
||||
elements.downloadUrl.value = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
elements.downloadError.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(path) {
|
||||
elements.actionError.textContent = "";
|
||||
try {
|
||||
await api.extract(state.selectedJobId, path);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
elements.actionError.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveSelection() {
|
||||
elements.actionError.textContent = "";
|
||||
const paths = topLevelSelection(state.selection);
|
||||
if (paths.length === 0) return;
|
||||
|
||||
try {
|
||||
await api.move(state.selectedJobId, {
|
||||
paths,
|
||||
destination: elements.librarySelect.value,
|
||||
subfolder: elements.subfolderInput.value.trim(),
|
||||
cleanup: elements.cleanupCheckbox.checked,
|
||||
});
|
||||
elements.subfolderInput.value = "";
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
elements.actionError.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteJob(job) {
|
||||
const question = isActive(job)
|
||||
? `Cancel and remove "${job.fileName}"?`
|
||||
: `Remove "${job.fileName}" and its temporary files?`;
|
||||
if (!window.confirm(question)) return;
|
||||
|
||||
try {
|
||||
await api.remove(job.id);
|
||||
if (job.id === state.selectedJobId) closeDetail();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.logout();
|
||||
} finally {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
onUnauthorized(showLogin);
|
||||
setupLogin(elements.loginForm, elements.loginError, showApp);
|
||||
elements.downloadForm.addEventListener("submit", submitDownload);
|
||||
elements.moveButton.addEventListener("click", moveSelection);
|
||||
elements.closeDetailButton.addEventListener("click", () => {
|
||||
closeDetail();
|
||||
renderJobList();
|
||||
});
|
||||
elements.logoutButton.addEventListener("click", logout);
|
||||
elements.settingsButton.addEventListener("click", showSettings);
|
||||
setupSettings((settings) => {
|
||||
applyDefaults(settings);
|
||||
refresh();
|
||||
});
|
||||
|
||||
try {
|
||||
const session = await api.session();
|
||||
if (session && session.authenticated) await showApp();
|
||||
else showLogin();
|
||||
} catch {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
export function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
export function button(label, className, onClick) {
|
||||
const node = element("button", className, label);
|
||||
node.type = "button";
|
||||
node.addEventListener("click", onClick);
|
||||
return node;
|
||||
}
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
export const statusLabels = {
|
||||
queued: "Queued",
|
||||
downloading: "Downloading",
|
||||
extracting: "Extracting",
|
||||
ready: "Ready",
|
||||
moving: "Moving",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
export function formatBytes(bytes) {
|
||||
let value = Math.max(0, bytes || 0);
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function percentOf(done, total) {
|
||||
if (!total || total <= 0) return null;
|
||||
return Math.min(100, (done / total) * 100);
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
import { button, element } from "./dom.js";
|
||||
import { formatBytes, percentOf, statusLabels } from "./format.js";
|
||||
|
||||
const activeStatuses = new Set(["queued", "downloading", "extracting", "moving"]);
|
||||
const speedSamples = new Map();
|
||||
|
||||
export function isActive(job) {
|
||||
return activeStatuses.has(job.status);
|
||||
}
|
||||
|
||||
export function updateSpeeds(jobs) {
|
||||
const now = performance.now();
|
||||
const seenIds = new Set();
|
||||
|
||||
for (const job of jobs) {
|
||||
seenIds.add(job.id);
|
||||
const previous = speedSamples.get(job.id);
|
||||
let speed = 0;
|
||||
if (previous && job.bytesDone >= previous.bytes && now > previous.time) {
|
||||
speed = ((job.bytesDone - previous.bytes) * 1000) / (now - previous.time);
|
||||
}
|
||||
speedSamples.set(job.id, { bytes: job.bytesDone, time: now, speed });
|
||||
}
|
||||
|
||||
for (const id of speedSamples.keys()) {
|
||||
if (!seenIds.has(id)) speedSamples.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export function renderJobs(container, jobs, selectedId, handlers) {
|
||||
container.replaceChildren(...jobs.map((job) => renderJob(job, job.id === selectedId, handlers)));
|
||||
}
|
||||
|
||||
function renderJob(job, selected, handlers) {
|
||||
const item = element("li", selected ? "job selected" : "job");
|
||||
|
||||
const header = element("div", "job-header");
|
||||
header.append(
|
||||
element("span", "job-name", job.fileName || job.sourceUrl),
|
||||
element("span", `badge badge-${job.status}`, statusLabels[job.status] || job.status),
|
||||
);
|
||||
item.append(header);
|
||||
|
||||
if (job.sourceUrl) item.append(element("p", "job-source muted", job.sourceUrl));
|
||||
if (job.status !== "completed") item.append(renderProgress(job));
|
||||
if (job.message) {
|
||||
const messageClass = job.status === "failed" ? "job-message error" : "job-message muted";
|
||||
item.append(element("p", messageClass, job.message));
|
||||
}
|
||||
item.append(renderActions(job, handlers));
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderProgress(job) {
|
||||
const wrapper = element("div");
|
||||
const track = element("div", "progress");
|
||||
const bar = element("div", "progress-bar");
|
||||
const percent = percentOf(job.bytesDone, job.bytesTotal);
|
||||
|
||||
if (percent === null && isActive(job)) {
|
||||
bar.classList.add("indeterminate");
|
||||
} else {
|
||||
const width = percent === null ? (job.status === "ready" ? 100 : 0) : percent;
|
||||
bar.style.width = `${width}%`;
|
||||
}
|
||||
|
||||
track.append(bar);
|
||||
wrapper.append(track, element("div", "progress-text muted", progressText(job, percent)));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function progressText(job, percent) {
|
||||
const parts = [];
|
||||
if (job.bytesTotal > 0) {
|
||||
parts.push(`${formatBytes(job.bytesDone)} of ${formatBytes(job.bytesTotal)}`);
|
||||
} else if (job.bytesDone > 0) {
|
||||
parts.push(formatBytes(job.bytesDone));
|
||||
}
|
||||
if (percent !== null) parts.push(`${percent.toFixed(1)}%`);
|
||||
|
||||
const sample = speedSamples.get(job.id);
|
||||
if (isActive(job) && sample && sample.speed > 0) parts.push(`${formatBytes(sample.speed)}/s`);
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
function renderActions(job, handlers) {
|
||||
const actions = element("div", "job-actions");
|
||||
if (job.status === "ready") {
|
||||
actions.append(button("Browse files", "secondary small", () => handlers.onBrowse(job)));
|
||||
}
|
||||
if (job.status !== "moving") {
|
||||
actions.append(button(isActive(job) ? "Cancel" : "Remove", "danger small", () => handlers.onDelete(job)));
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
import { api } from "./api.js";
|
||||
|
||||
export function setupLogin(form, errorElement, onSuccess) {
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
errorElement.textContent = "";
|
||||
const formData = new FormData(form);
|
||||
|
||||
try {
|
||||
await api.login(formData.get("username"), formData.get("password"));
|
||||
form.reset();
|
||||
await onSuccess();
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { api } from "./api.js";
|
||||
import { element } from "./dom.js";
|
||||
|
||||
const themeStorageKey = "ddc-theme";
|
||||
|
||||
const fields = {
|
||||
dialog: document.getElementById("settings-dialog"),
|
||||
form: document.getElementById("settings-form"),
|
||||
closeButton: document.getElementById("settings-close"),
|
||||
cancelButton: document.getElementById("settings-cancel"),
|
||||
error: document.getElementById("settings-error"),
|
||||
concurrent: document.getElementById("setting-concurrent"),
|
||||
minFree: document.getElementById("setting-min-free"),
|
||||
ports: document.getElementById("setting-ports"),
|
||||
autoExtract: document.getElementById("setting-auto-extract"),
|
||||
cleanup: document.getElementById("setting-cleanup"),
|
||||
theme: document.getElementById("setting-theme"),
|
||||
downloadDir: document.getElementById("info-download-dir"),
|
||||
roots: document.getElementById("info-roots"),
|
||||
};
|
||||
|
||||
export function setupSettings(onSaved) {
|
||||
fields.closeButton.addEventListener("click", () => fields.dialog.close());
|
||||
fields.cancelButton.addEventListener("click", () => fields.dialog.close());
|
||||
fields.form.addEventListener("submit", (event) => saveSettings(event, onSaved));
|
||||
fields.theme.addEventListener("change", () => applyTheme(fields.theme.value));
|
||||
fields.dialog.addEventListener("close", () => applyTheme(readTheme()));
|
||||
}
|
||||
|
||||
export async function loadSettings() {
|
||||
const data = await api.settings();
|
||||
return data.settings;
|
||||
}
|
||||
|
||||
export async function openSettings() {
|
||||
fields.error.textContent = "";
|
||||
const data = await api.settings();
|
||||
fillForm(data);
|
||||
fields.dialog.showModal();
|
||||
}
|
||||
|
||||
function fillForm(data) {
|
||||
const settings = data.settings;
|
||||
fields.concurrent.value = settings.maxConcurrentDownloads;
|
||||
fields.minFree.value = settings.minFreeGB;
|
||||
fields.ports.value = settings.allowedPorts.length > 0 ? settings.allowedPorts.join(", ") : "*";
|
||||
fields.autoExtract.checked = settings.autoExtract;
|
||||
fields.cleanup.checked = settings.cleanupAfterMove;
|
||||
fields.theme.value = readTheme();
|
||||
fields.downloadDir.textContent = data.downloadDir;
|
||||
fields.roots.replaceChildren(...data.libraryRoots.map((root) => element("div", "mono", root)));
|
||||
}
|
||||
|
||||
async function saveSettings(event, onSaved) {
|
||||
event.preventDefault();
|
||||
fields.error.textContent = "";
|
||||
try {
|
||||
const payload = {
|
||||
maxConcurrentDownloads: Number(fields.concurrent.value),
|
||||
minFreeGB: Number(fields.minFree.value),
|
||||
allowedPorts: parsePorts(fields.ports.value),
|
||||
autoExtract: fields.autoExtract.checked,
|
||||
cleanupAfterMove: fields.cleanup.checked,
|
||||
};
|
||||
const data = await api.saveSettings(payload);
|
||||
storeTheme(fields.theme.value);
|
||||
fields.dialog.close();
|
||||
onSaved(data.settings);
|
||||
} catch (error) {
|
||||
fields.error.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePorts(text) {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "*") return [];
|
||||
const ports = trimmed.split(/[\s,]+/).filter(Boolean).map(Number);
|
||||
const invalid = ports.length === 0 || ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535);
|
||||
if (invalid) throw new Error("Allowed ports must be numbers between 1 and 65535, or * for any port.");
|
||||
return ports;
|
||||
}
|
||||
|
||||
function readTheme() {
|
||||
try {
|
||||
return localStorage.getItem(themeStorageKey) || "system";
|
||||
} catch {
|
||||
return "system";
|
||||
}
|
||||
}
|
||||
|
||||
function storeTheme(theme) {
|
||||
try {
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
applyTheme(theme);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||
else delete document.documentElement.dataset.theme;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem("ddc-theme");
|
||||
if (theme === "light" || theme === "dark") {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
})();
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
import { button, element } from "./dom.js";
|
||||
import { formatBytes } from "./format.js";
|
||||
|
||||
export function renderTree(container, nodes, selection, handlers) {
|
||||
if (nodes.length === 0) {
|
||||
container.replaceChildren(element("p", "muted", "No files found."));
|
||||
return;
|
||||
}
|
||||
container.replaceChildren(buildList(nodes, selection, handlers));
|
||||
}
|
||||
|
||||
export function topLevelSelection(selection) {
|
||||
const paths = [...selection].sort();
|
||||
return paths.filter((path) => !paths.some((other) => other !== path && path.startsWith(`${other}/`)));
|
||||
}
|
||||
|
||||
function buildList(nodes, selection, handlers) {
|
||||
const list = element("ul", "tree-list");
|
||||
for (const node of nodes) list.append(buildNode(node, selection, handlers));
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildNode(node, selection, handlers) {
|
||||
const item = element("li", "tree-item");
|
||||
const row = element("div", "tree-row");
|
||||
const label = element("label", "tree-label");
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.checked = selection.has(node.path);
|
||||
checkbox.addEventListener("change", () => {
|
||||
if (checkbox.checked) selection.add(node.path);
|
||||
else selection.delete(node.path);
|
||||
handlers.onSelectionChange();
|
||||
});
|
||||
|
||||
const displayName = node.isDir ? `${node.name}/` : node.name;
|
||||
label.append(checkbox, element("span", node.isDir ? "tree-name folder" : "tree-name", displayName));
|
||||
row.append(label, element("span", "tree-size", formatBytes(node.size)));
|
||||
|
||||
if (node.isArchive) {
|
||||
row.append(button("Extract", "secondary small", () => handlers.onExtract(node.path)));
|
||||
}
|
||||
|
||||
item.append(row);
|
||||
if (node.children && node.children.length > 0) {
|
||||
item.append(buildList(node.children, selection, handlers));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
Reference in new issue
Block a user