Initial DDC release

This commit is contained in:
SirBlob committed 2026-10-25 20:24:50 -04:00
commit 434f6441da
57 files changed
+4606

No files matched your search

+162
View File
@@ -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>
+61
View File
@@ -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)),
};
+293
View File
@@ -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();
+13
View File
@@ -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;
}
+27
View File
@@ -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);
}
+95
View File
@@ -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;
}
+17
View File
@@ -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;
}
});
}
+104
View File
@@ -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;
}
+10
View File
@@ -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;
}
})();
+50
View File
@@ -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;
}
+726
View File
@@ -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;
}
}