Initial DDC release
This commit is contained in:
commit
434f6441da
57 files changed
+4606
No files matched your search
Executable
+162
@@ -0,0 +1,162 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#16181d">
|
||||
<title>DDC</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="/js/theme.js"></script>
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<section id="login-view" class="login-view" hidden>
|
||||
<form id="login-form" class="card login-card">
|
||||
<div class="login-brand">
|
||||
<span class="brand-mark">DDC</span>
|
||||
<p class="muted">Direct Download Client</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Username</span>
|
||||
<input name="username" autocomplete="username" autocapitalize="off" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Password</span>
|
||||
<input name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button type="submit" class="primary block">Sign in</button>
|
||||
<p id="login-error" class="error" role="alert"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="app-view" class="app" hidden>
|
||||
<header class="topbar">
|
||||
<div class="topbar-inner">
|
||||
<span class="brand-mark">DDC</span>
|
||||
<span id="disk-status" class="chip"></span>
|
||||
<div class="topbar-actions">
|
||||
<button id="settings-button" type="button" class="ghost">Settings</button>
|
||||
<button id="logout-button" type="button" class="ghost">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="card">
|
||||
<h2>New download</h2>
|
||||
<form id="download-form" class="download-form">
|
||||
<input id="download-url" type="url" inputmode="url" placeholder="https://example.com/file.zip" autocomplete="off" autocapitalize="off" required>
|
||||
<button type="submit" class="primary">Download</button>
|
||||
</form>
|
||||
<label class="checkbox">
|
||||
<input id="auto-extract" type="checkbox" checked>
|
||||
<span>Extract archives automatically</span>
|
||||
</label>
|
||||
<p id="download-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Downloads</h2>
|
||||
<ul id="job-list" class="job-list"></ul>
|
||||
<p id="job-empty" class="empty-state">No downloads yet. Paste a link above to start.</p>
|
||||
</section>
|
||||
|
||||
<section id="job-detail" class="card" hidden>
|
||||
<div class="detail-header">
|
||||
<h2 id="detail-title"></h2>
|
||||
<button id="close-detail-button" type="button" class="ghost small">Close</button>
|
||||
</div>
|
||||
<div id="file-tree" class="file-tree"></div>
|
||||
<p id="selection-summary" class="muted small-text"></p>
|
||||
<div class="move-controls">
|
||||
<label class="field">
|
||||
<span>Destination</span>
|
||||
<select id="library-select"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Subfolder (optional)</span>
|
||||
<input id="subfolder-input" placeholder="Movie Name (2024)" autocomplete="off">
|
||||
</label>
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input id="cleanup-checkbox" type="checkbox" checked>
|
||||
<span>Delete remaining temporary files after the move</span>
|
||||
</label>
|
||||
<button id="move-button" type="button" class="primary block-mobile" disabled>Move</button>
|
||||
<p id="action-error" class="error" role="alert"></p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="settings-dialog" class="sheet">
|
||||
<form id="settings-form" class="sheet-form">
|
||||
<header class="sheet-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="settings-close" type="button" class="ghost small">Close</button>
|
||||
</header>
|
||||
|
||||
<div class="sheet-body">
|
||||
<fieldset class="settings-group">
|
||||
<legend>Downloads</legend>
|
||||
<div class="settings-grid">
|
||||
<label class="field">
|
||||
<span>Parallel downloads</span>
|
||||
<input id="setting-concurrent" type="number" inputmode="numeric" min="1" max="10" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Keep free on disk (GB)</span>
|
||||
<input id="setting-min-free" type="number" inputmode="numeric" min="0" required>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Allowed ports</span>
|
||||
<input id="setting-ports" placeholder="80, 443 or * for any" autocomplete="off">
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Defaults</legend>
|
||||
<label class="checkbox">
|
||||
<input id="setting-auto-extract" type="checkbox">
|
||||
<span>Extract archives automatically</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="setting-cleanup" type="checkbox">
|
||||
<span>Delete temporary files after a move</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Appearance</legend>
|
||||
<label class="field">
|
||||
<span>Theme (this browser)</span>
|
||||
<select id="setting-theme">
|
||||
<option value="system">System</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Server</legend>
|
||||
<dl class="info-list">
|
||||
<dt>Download folder</dt>
|
||||
<dd id="info-download-dir" class="mono"></dd>
|
||||
<dt>Destination roots</dt>
|
||||
<dd id="info-roots"></dd>
|
||||
</dl>
|
||||
</fieldset>
|
||||
|
||||
<p id="settings-error" class="error" role="alert"></p>
|
||||
</div>
|
||||
|
||||
<footer class="sheet-footer">
|
||||
<button id="settings-cancel" type="button" class="ghost">Cancel</button>
|
||||
<button type="submit" class="primary">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
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;
|
||||
}
|
||||
Executable
+726
@@ -0,0 +1,726 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--background: #f2f3f6;
|
||||
--surface: #ffffff;
|
||||
--surface-alt: #f7f8fa;
|
||||
--surface-hover: #eef0f4;
|
||||
--border: #dde0e6;
|
||||
--text: #191c22;
|
||||
--muted: #687080;
|
||||
--accent: #3b6ef5;
|
||||
--accent-hover: #2f5ddb;
|
||||
--accent-soft: rgba(59, 110, 245, 0.12);
|
||||
--accent-text: #ffffff;
|
||||
--danger: #d03c32;
|
||||
--danger-soft: rgba(208, 60, 50, 0.1);
|
||||
--success: #1f9d62;
|
||||
--success-soft: rgba(31, 157, 98, 0.12);
|
||||
--info: #3b6ef5;
|
||||
--info-soft: rgba(59, 110, 245, 0.12);
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, 0.05), 0 2px 8px rgba(16, 24, 40, 0.04);
|
||||
--radius: 12px;
|
||||
--radius-small: 8px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
--background: #0f1115;
|
||||
--surface: #171a20;
|
||||
--surface-alt: #1d2128;
|
||||
--surface-hover: #242933;
|
||||
--border: #2a2f39;
|
||||
--text: #e7e9ee;
|
||||
--muted: #949cab;
|
||||
--accent: #5b87ff;
|
||||
--accent-hover: #7298ff;
|
||||
--accent-soft: rgba(91, 135, 255, 0.16);
|
||||
--danger: #f06a5f;
|
||||
--danger-soft: rgba(240, 106, 95, 0.14);
|
||||
--success: #3fc486;
|
||||
--success-soft: rgba(63, 196, 134, 0.14);
|
||||
--info: #5b87ff;
|
||||
--info-soft: rgba(91, 135, 255, 0.16);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--background: #0f1115;
|
||||
--surface: #171a20;
|
||||
--surface-alt: #1d2128;
|
||||
--surface-hover: #242933;
|
||||
--border: #2a2f39;
|
||||
--text: #e7e9ee;
|
||||
--muted: #949cab;
|
||||
--accent: #5b87ff;
|
||||
--accent-hover: #7298ff;
|
||||
--accent-soft: rgba(91, 135, 255, 0.16);
|
||||
--danger: #f06a5f;
|
||||
--danger-soft: rgba(240, 106, 95, 0.14);
|
||||
--success: #3fc486;
|
||||
--success-soft: rgba(63, 196, 134, 0.14);
|
||||
--info: #5b87ff;
|
||||
--info-soft: rgba(91, 135, 255, 0.16);
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
margin: 0 0 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]),
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface-alt);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]):focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 40px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface-alt);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.ghost,
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
button.ghost:hover,
|
||||
button.secondary:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
button.small {
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.small-text {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.85rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
margin: 10px 0 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 550;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
font-weight: 400;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
min-height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
font-weight: 800;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-alt);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-brand .brand-mark {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.login-brand p {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: color-mix(in srgb, var(--surface) 88%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.download-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 24px 8px;
|
||||
margin: 0;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
}
|
||||
|
||||
.job-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 14px;
|
||||
background: var(--surface-alt);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.job.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.job-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.job-name {
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.job-source {
|
||||
font-size: 0.8rem;
|
||||
margin: 2px 0 10px;
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.job-message {
|
||||
font-size: 0.88rem;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.job-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-ready,
|
||||
.badge-completed {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.badge-failed,
|
||||
.badge-cancelled {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.badge-downloading,
|
||||
.badge-extracting,
|
||||
.badge-moving,
|
||||
.badge-queued {
|
||||
color: var(--info);
|
||||
background: var(--info-soft);
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--accent);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-bar.indeterminate {
|
||||
width: 30% !important;
|
||||
animation: slide 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(340%);
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.82rem;
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
max-height: 440px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
padding: 6px;
|
||||
background: var(--surface-alt);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.file-tree > .muted {
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-list .tree-list {
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--border);
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px;
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tree-row:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tree-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.tree-name.folder {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-size {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.move-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: min(560px, calc(100% - 32px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.sheet::backdrop {
|
||||
background: rgba(10, 12, 16, 0.55);
|
||||
}
|
||||
|
||||
.sheet-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
|
||||
.sheet-header,
|
||||
.sheet-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sheet-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sheet-body {
|
||||
padding: 4px 20px 16px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 16px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-group legend {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-group .checkbox {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 16px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 12px 12px calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.topbar-inner {
|
||||
padding: 8px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.topbar-actions button {
|
||||
min-height: 36px;
|
||||
padding: 6px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.download-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.download-form button,
|
||||
button.block-mobile {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.move-controls,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.job-actions button {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.tree-list .tree-list {
|
||||
padding-left: 12px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 92dvh;
|
||||
margin: auto 0 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sheet-form {
|
||||
max-height: 92dvh;
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sheet-footer button {
|
||||
flex: 1;
|
||||
min-height: 46px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.chip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user