Initial DDC release
This commit is contained in:
commit
434f6441da
57 files changed
+4606
No files matched your search
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();
|
||||
Reference in new issue
Block a user