336 lines
27 KiB
Svelte
336 lines
27 KiB
Svelte
<script lang="ts">
|
|
import { onMount, tick } from 'svelte';
|
|
import { page } from '$app/stores';
|
|
import { beforeNavigate } from '$app/navigation';
|
|
import type { GameLibrary, GameLibraryVersion } from '$lib/game-files.js';
|
|
import { formatGameBytes } from '$lib/game-files.js';
|
|
import type { Host } from '$lib/hosts.js';
|
|
import { selectGameFolder, type SelectedGameFolder } from '$ui/game-folder.js';
|
|
import { pollVisible } from '$ui/poll.js';
|
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
|
import Button from '$ui/components/primitives/Button.svelte';
|
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
|
import GameFileTransferProgress from '$ui/components/GameFileTransferProgress.svelte';
|
|
|
|
type OverviewLibrary = Omit<GameLibrary, 'versions'> & { versions: Omit<GameLibraryVersion, 'files'>[] };
|
|
interface UploadFile { path: string; size: number; lastModified: number; receivedBytes: number; complete: boolean }
|
|
interface UploadStatus { status: 'pending' | 'verifying' | 'published' | 'failed'; chunkSize: number; files: UploadFile[]; error?: string | null; version: { id: string; state: string } }
|
|
type Phase = 'idle' | 'preparing' | 'uploading' | 'paused' | 'verifying' | 'published';
|
|
let libraries = $state<OverviewLibrary[]>([]); let hosts = $state<Host[]>([]);
|
|
let loaded = $state(false); let error = $state(''); let notice = $state(''); let busy = $state(false);
|
|
let showUpload = $state(false); let game = $state<'t5' | 't6'>('t5');
|
|
let targets = $state<Record<string, string>>({});
|
|
let folder = $state.raw<SelectedGameFolder | null>(null);
|
|
let uploadStatus = $state.raw<UploadStatus | null>(null);
|
|
let uploadId = $state(''); let uploadBusy = $state(false); let uploadError = $state('');
|
|
let phase = $state<Phase>('idle'); let currentPath = $state(''); let pauseRequested = $state(false);
|
|
let acceptedBytes = $state(0); let sendingBytes = $state(0); let completedFiles = $state(0);
|
|
let folderInput = $state<HTMLInputElement>();
|
|
const activeRequests = new Map<XMLHttpRequest, number>(); let disposed = false; let failedTransfer = false;
|
|
const updateSending = () => { sendingBytes = [...activeRequests.values()].reduce((a, b) => a + b, 0); };
|
|
const abortUploads = () => { for (const request of activeRequests.keys()) request.abort(); };
|
|
const canManage = $derived($page.data.user?.role === 'admin');
|
|
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
|
|
const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0);
|
|
const totalFiles = $derived(folder?.files.length ?? uploadStatus?.files.length ?? 0);
|
|
const progressBytes = $derived(Math.min(totalBytes, acceptedBytes + sendingBytes));
|
|
const transferring = $derived(uploadBusy && (phase === 'preparing' || phase === 'uploading'));
|
|
const hostName = (id: string | null) => id ? hosts.find(host => host.id === id)?.name ?? id : 'Browser upload';
|
|
const gameName = (game: 't5' | 't6') => game === 't5' ? 'Black Ops I (T5)' : 'Black Ops II (T6)';
|
|
const uploadDate = (item: OverviewLibrary['versions'][number]) => new Date(item.publishedAt ?? item.createdAt).toLocaleString();
|
|
function automaticDownloadDetail(host: Host | undefined): string {
|
|
if (host?.decommission) return 'Automatic setup is paused during host deletion.';
|
|
if (!host?.enrolled) return 'Waiting for host enrollment.';
|
|
if (host.mode !== 'active') return 'Automatic setup resumes when the host is active.';
|
|
if (!host.credentialActive || !host.connected) return 'Automatic setup resumes when the host reconnects.';
|
|
if (host.readiness === 'degraded') return host.readinessReasons[0] ?? 'The host needs attention before automatic setup can continue.';
|
|
return 'Download starts automatically after runtime preparation and earlier work finish.';
|
|
}
|
|
|
|
async function responseBody(response: Response) {
|
|
let body;
|
|
try { body = await response.json(); } catch { throw new Error('The controller returned an unexpected response. Sign in again if your session expired, then resume the upload.'); }
|
|
if (!response.ok) throw new Error(body.error ?? 'The controller could not complete this request.');
|
|
return body;
|
|
}
|
|
async function load() {
|
|
try {
|
|
const body = await responseBody(await fetch('/api/game-files'));
|
|
libraries = body.libraries; hosts = body.hosts; loaded = true;
|
|
} catch (cause) { error = (cause as Error).message; }
|
|
}
|
|
async function download(versionId: string) {
|
|
busy = true; error = ''; notice = '';
|
|
try {
|
|
await responseBody(await fetch(`/api/game-files/${versionId}/download`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hostId: targets[versionId] }) }));
|
|
notice = 'Game files queued for download. Progress will update below.'; await load();
|
|
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
|
|
}
|
|
function resetUpload() {
|
|
folder = null; uploadStatus = null; uploadId = ''; phase = 'idle'; uploadError = ''; currentPath = '';
|
|
acceptedBytes = 0; sendingBytes = 0; completedFiles = 0; pauseRequested = false;
|
|
if (folderInput) folderInput.value = '';
|
|
}
|
|
async function newUpload(library: OverviewLibrary) {
|
|
resetUpload(); showUpload = true; game = library.game;
|
|
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
function chooseFolder(event: Event) {
|
|
uploadError = ''; folder = null;
|
|
const input = event.currentTarget as HTMLInputElement;
|
|
if (!input.files?.length) return;
|
|
try {
|
|
const selected = selectGameFolder(Array.from(input.files));
|
|
if (uploadStatus) checkSelection(selected, uploadStatus);
|
|
folder = selected;
|
|
} catch (cause) { uploadError = (cause as Error).message; input.value = ''; }
|
|
}
|
|
function checkSelection(selected: SelectedGameFolder, status: UploadStatus) {
|
|
const originals = new Map(status.files.map(file => [file.path, file]));
|
|
if (selected.files.length !== originals.size || selected.files.some(({ path, file }) => {
|
|
const original = originals.get(path); return !original || original.size !== file.size || original.lastModified !== file.lastModified;
|
|
})) throw new Error('This folder does not match the original upload. Select the same unchanged folder, including its original file names, sizes, and modification dates. Start a new upload for changed game files.');
|
|
}
|
|
function applyStatus(status: UploadStatus) {
|
|
uploadStatus = status;
|
|
acceptedBytes = status.files.reduce((total, file) => total + file.receivedBytes, 0);
|
|
completedFiles = status.files.filter(file => file.complete).length; sendingBytes = 0;
|
|
}
|
|
async function getStatus(): Promise<UploadStatus> {
|
|
const status = await responseBody(await fetch(`/api/game-files/${uploadId}/upload`)) as UploadStatus;
|
|
applyStatus(status); return status;
|
|
}
|
|
async function followVerification(initial: UploadStatus) {
|
|
let status = initial; phase = 'verifying'; currentPath = '';
|
|
while (!disposed && status.status === 'verifying') {
|
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
if (disposed) return;
|
|
status = await getStatus();
|
|
}
|
|
if (disposed) return;
|
|
if (status.status !== 'published') throw new Error(status.error || 'Verification did not finish. Retry this upload to continue.');
|
|
phase = 'published'; folder = null; notice = `${game.toUpperCase()} game files are ready. Active hosts will download and verify the latest files automatically.`;
|
|
if (folderInput) folderInput.value = '';
|
|
await load();
|
|
}
|
|
async function resumeUpload(library: OverviewLibrary, item: OverviewLibrary['versions'][number]) {
|
|
resetUpload(); showUpload = true; uploadId = item.id; game = library.game;
|
|
uploadBusy = true;
|
|
try {
|
|
const status = await getStatus();
|
|
if (status.status === 'verifying' || status.status === 'published') await followVerification(status);
|
|
else { phase = 'paused'; uploadError = status.error ?? ''; }
|
|
} catch (cause) { uploadError = (cause as Error).message; phase = 'paused'; }
|
|
finally { uploadBusy = false; }
|
|
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
function pauseUpload() { pauseRequested = true; abortUploads(); }
|
|
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> {
|
|
const chunk = file.slice(offset, offset + length);
|
|
const hash = await crypto.subtle.digest('SHA-256', await chunk.arrayBuffer());
|
|
if (pauseRequested || disposed || failedTransfer) throw new Error('Upload paused.');
|
|
const sha256 = [...new Uint8Array(hash)].map(value => value.toString(16).padStart(2, '0')).join('');
|
|
return new Promise((resolve, reject) => {
|
|
const request = new XMLHttpRequest(); activeRequests.set(request, 0);
|
|
request.open('PUT', `/api/game-files/${uploadId}/upload/files/${path.split('/').map(encodeURIComponent).join('/')}`);
|
|
request.setRequestHeader('Content-Type', 'application/octet-stream');
|
|
request.setRequestHeader('X-Upload-Offset', String(offset)); request.setRequestHeader('X-Upload-Length', String(length));
|
|
request.setRequestHeader('X-Content-SHA256', sha256); request.timeout = 300_000;
|
|
request.upload.onprogress = event => { activeRequests.set(request, Math.min(length, event.loaded)); updateSending(); };
|
|
request.onload = () => {
|
|
activeRequests.delete(request); updateSending();
|
|
try {
|
|
const body = JSON.parse(request.responseText);
|
|
if (request.status < 200 || request.status >= 300) throw new Error(body.error ?? `Upload failed (${request.status}).`);
|
|
if (body.receivedBytes !== offset + length) throw new Error('The upload position changed. Resume to check the saved progress.');
|
|
resolve(body.receivedBytes);
|
|
} catch (cause) { reject(cause); }
|
|
};
|
|
request.onerror = () => { activeRequests.delete(request); updateSending(); reject(new Error('The connection was interrupted. Resume to continue from the last saved chunk.')); };
|
|
request.ontimeout = () => { activeRequests.delete(request); updateSending(); reject(new Error('This chunk timed out. Resume to check saved progress and retry.')); };
|
|
request.onabort = () => { activeRequests.delete(request); updateSending(); reject(new Error('Upload paused.')); };
|
|
request.send(chunk);
|
|
});
|
|
}
|
|
async function startUpload(event: SubmitEvent) {
|
|
event.preventDefault(); if (uploadBusy || !folder) return;
|
|
if (!globalThis.crypto?.subtle) { uploadError = 'Folder uploads require a secure connection. Open this controller over HTTPS or localhost.'; return; }
|
|
uploadBusy = true; pauseRequested = false; failedTransfer = false; uploadError = ''; notice = ''; phase = 'preparing';
|
|
try {
|
|
if (!uploadId) {
|
|
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
|
|
uploadId = result.version.id;
|
|
}
|
|
const status = await getStatus();
|
|
if (status.status === 'verifying' || status.status === 'published') { await followVerification(status); return; }
|
|
checkSelection(folder, status);
|
|
const localFiles = new Map(folder.files.map(item => [item.path, item.file]));
|
|
const chunkSize = Math.min(8_388_608, Math.max(1, status.chunkSize));
|
|
phase = 'uploading';
|
|
let nextFile = 0;
|
|
let failure: unknown;
|
|
const transfer = async () => {
|
|
while (!pauseRequested && !disposed && !failure) {
|
|
const remote = status.files[nextFile++];
|
|
if (!remote) return;
|
|
if (remote.complete) continue;
|
|
currentPath = remote.path;
|
|
const file = localFiles.get(remote.path)!;
|
|
try {
|
|
do {
|
|
if (pauseRequested || disposed || failure) return;
|
|
const length = Math.min(chunkSize, remote.size - remote.receivedBytes);
|
|
remote.receivedBytes = await sendChunk(remote.path, file, remote.receivedBytes, length);
|
|
acceptedBytes += length;
|
|
if (remote.receivedBytes === remote.size) { remote.complete = true; completedFiles++; }
|
|
} while (!remote.complete);
|
|
} catch (cause) { failure ??= cause; failedTransfer = true; abortUploads(); }
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(3, status.files.length) }, transfer));
|
|
if (failure) throw failure;
|
|
if (pauseRequested || disposed) { phase = 'paused'; return; }
|
|
phase = 'verifying'; currentPath = '';
|
|
const verification = await responseBody(await fetch(`/api/game-files/${uploadId}/upload/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })) as UploadStatus;
|
|
applyStatus(verification); await followVerification(verification);
|
|
} catch (cause) {
|
|
if (!disposed) { uploadError = pauseRequested ? '' : (cause as Error).message; phase = 'paused'; }
|
|
} finally { uploadBusy = false; sendingBytes = 0; if (!disposed) void load(); }
|
|
}
|
|
beforeNavigate(navigation => {
|
|
if (!transferring) return;
|
|
if (navigation.type === 'leave' || !window.confirm('This upload is still transferring files. Leave this page? You can resume later by selecting the original unchanged folder.')) navigation.cancel();
|
|
});
|
|
onMount(() => {
|
|
const stop = pollVisible(load, 5000);
|
|
return () => { disposed = true; pauseRequested = true; abortUploads(); stop(); };
|
|
});
|
|
</script>
|
|
|
|
<svelte:head><title>Game Files | Electric Cherry</title></svelte:head>
|
|
<div class="page">
|
|
<SectionHeader title="Game Files" subtitle="Upload your T5 and T6 game folders. Hosts automatically keep a verified copy of the latest files for each game." />
|
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
|
{#if notice}<p role="status">{notice}</p>{/if}
|
|
{#if !canManage}<p class="muted">Administrators upload game files. Active hosts prepare verified copies automatically.</p>{/if}
|
|
{#if canManage && showUpload}
|
|
<section class="card" id="game-upload">
|
|
<div class="heading"><h2>{phase === 'published' ? `${game.toUpperCase()} files uploaded` : uploadId ? `Continue ${game.toUpperCase()} upload` : `Upload ${game.toUpperCase()} game folder`}</h2><Button size="sm" variant="ghost" disabled={uploadBusy} onclick={() => showUpload = false}>Close</Button></div>
|
|
<p>Select your complete {gameName(game)} game folder, including its installed DLC. Once verified, this upload becomes the latest files and hosts download it automatically.</p>
|
|
{#if uploadError}<p class="ui-error" role="alert">{uploadError}</p>{/if}
|
|
<form onsubmit={startUpload}>
|
|
{#if phase !== 'verifying' && phase !== 'published'}
|
|
<label class="folder-picker">{uploadId ? 'Select the original unchanged folder' : 'Game folder'}<input bind:this={folderInput} type="file" webkitdirectory multiple onchange={chooseFolder} disabled={uploadBusy} aria-describedby="folder-help" /></label>
|
|
<p class="muted" id="folder-help">{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}</p>
|
|
{/if}
|
|
{#if folder}<div class="folder-summary"><strong>{folder.name}</strong><span>{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}</span><details><summary>Preview folder paths</summary><ul class="path-list">{#each folder.files.slice(0, 8) as file}<li>{file.path}</li>{/each}</ul>{#if folder.files.length > 8}<p class="muted">And {(folder.files.length - 8).toLocaleString()} more files.</p>{/if}</details></div>{/if}
|
|
{#if phase !== 'idle'}<div class="upload-progress" aria-live="polite">
|
|
<div class="heading"><strong>{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying files…' : phase === 'published' ? 'Ready · host downloads are automatic' : 'Upload paused'}</strong><span>{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files</span></div>
|
|
<progress max={totalBytes || Math.max(1, totalFiles)} value={totalBytes ? progressBytes : completedFiles} aria-label="Game folder upload progress"></progress>
|
|
<p>{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring</p>
|
|
{#if currentPath}<p class="current-file">{currentPath}</p>{/if}
|
|
{#if phase === 'verifying'}<p class="muted">The controller checks every file before making this upload available to hosts. Verification continues if you leave this page.</p>{:else if phase === 'paused'}<p class="muted">Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.</p>{/if}
|
|
</div>{/if}
|
|
<div class="upload-actions">
|
|
{#if transferring}<Button onclick={pauseUpload} disabled={pauseRequested}>{pauseRequested ? 'Pausing…' : 'Pause upload'}</Button>
|
|
{:else if phase !== 'verifying' && phase !== 'published'}<Button type="submit" variant="primary" disabled={uploadBusy || !folder}>{uploadId ? 'Resume upload' : `Upload ${game.toUpperCase()} files`}</Button>{/if}
|
|
{#if phase === 'idle' || phase === 'uploading'}<span class="muted">Keep this page open while files transfer. Interrupted uploads can be resumed below.</span>{/if}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
{/if}
|
|
{#if !loaded}<p role="status">Loading game files…</p>{/if}
|
|
{#each libraries as library (library.game)}
|
|
{@const latest = library.versions.find(item => item.id === library.latestVersionId)}
|
|
{@const unfinished = library.versions.filter(item => item.state !== 'ready')}
|
|
{@const history = library.versions.filter(item => item.state === 'ready' && item.id !== library.latestVersionId)}
|
|
<section class="card">
|
|
<div class="heading">
|
|
<div><h2>{gameName(library.game)}</h2><p class="muted">{latest ? 'Latest verified game files' : 'Game folder not uploaded yet'}</p></div>
|
|
{#if canManage}<Button size="sm" disabled={uploadBusy} onclick={() => newUpload(library)}>{latest ? `Update ${library.game.toUpperCase()} files` : `Upload ${library.game.toUpperCase()} files`}</Button>{/if}
|
|
</div>
|
|
{#if latest}
|
|
<div class="heading"><p>{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()} files · Uploaded {uploadDate(latest)}</p><Badge variant="online">Available</Badge></div>
|
|
<p class="muted">Active hosts keep these files ready automatically, including hosts added later. Running servers keep using their existing files.</p>
|
|
{@render hostCopies(latest, true)}
|
|
{@render uploadDetails(latest)}
|
|
{:else}
|
|
<p class="muted">Upload the complete {library.game.toUpperCase()} game folder once. Hosts will download and verify it automatically as part of setup.</p>
|
|
{/if}
|
|
{#if unfinished.length}
|
|
<div class="uploads">
|
|
<h3>Unfinished uploads</h3>
|
|
{#each unfinished as item (item.id)}
|
|
{@const browserUpload = !item.sourceHostId}
|
|
<article class="upload-entry">
|
|
<div class="heading"><p>Started {new Date(item.createdAt).toLocaleString()} · {formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p><Badge variant={item.state === 'failed' ? 'danger' : 'info'}>{item.state === 'failed' ? 'Failed' : browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing'}</Badge></div>
|
|
{#if item.error}<p class="ui-error">{item.error}</p>{/if}
|
|
{#if browserUpload}
|
|
<p class="muted">Resume with the original unchanged folder. The latest files stay available while this upload completes.</p>
|
|
{#if canManage}<div><Button size="sm" disabled={uploadBusy} onclick={() => resumeUpload(library, item)}>Resume upload</Button></div>{/if}
|
|
{:else if item.state === 'importing'}<p class="muted">The existing host import continues independently. It is reconciled when the host reconnects.</p>{/if}
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{#if history.length}
|
|
<details class="history">
|
|
<summary>Previous uploads ({history.length})</summary>
|
|
<p class="muted">Previous files remain available for servers that already use them.</p>
|
|
{#each history as item (item.id)}
|
|
<article class="upload-entry">
|
|
<h3>Uploaded {uploadDate(item)}</h3>
|
|
<p>{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files</p>
|
|
{@render uploadDetails(item)}
|
|
<details><summary>Host copies</summary>{@render hostCopies(item, false)}</details>
|
|
</article>
|
|
{/each}
|
|
</details>
|
|
{/if}
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
|
|
{#snippet uploadDetails(item: OverviewLibrary['versions'][number])}
|
|
<details><summary>Upload details</summary><dl><dt>Upload ID</dt><dd>{item.id}</dd><dt>Manifest SHA256</dt><dd>{item.manifestSha256 ?? 'Available after verification'}</dd><dt>Uploaded from</dt><dd>{!item.sourceHostId ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}</dd></dl><a href={`/api/game-files/${item.id}`} target="_blank" rel="noreferrer">View complete file manifest</a></details>
|
|
{/snippet}
|
|
|
|
{#snippet hostCopies(item: OverviewLibrary['versions'][number], latest: boolean)}
|
|
{#if !hosts.length}<p class="muted">Files are ready. <a href="/hosts">Add a host</a> to prepare them automatically.</p>{/if}
|
|
<div class="table-wrap"><table><thead><tr><th>Host</th><th>File status</th><th>Progress</th><th>Detail</th></tr></thead><tbody>
|
|
{#each item.caches as cache}
|
|
{@const cacheHost = hosts.find(host => host.id === cache.hostId)}
|
|
{@const awaitingAutomaticDownload = latest && cache.status === 'missing'}
|
|
<tr>
|
|
<td><a href={`/hosts/${cache.hostId}`}>{hostName(cache.hostId)}</a></td>
|
|
<td>{awaitingAutomaticDownload ? 'Waiting' : cache.status}{!cacheHost?.connected ? ' · offline' : ''}</td>
|
|
<td>{#if cache.status !== 'missing'}<GameFileTransferProgress {cache} connected={cacheHost?.connected ?? false} />{:else}—{/if}</td>
|
|
<td>{cache.error ?? (awaitingAutomaticDownload ? automaticDownloadDetail(cacheHost) : '')}{#if awaitingAutomaticDownload || cache.status === 'failed'} <a href={`/hosts/${cache.hostId}`}>View host setup</a>{/if}</td>
|
|
</tr>
|
|
{:else}<tr><td colspan="4">No host copies yet.</td></tr>{/each}
|
|
</tbody></table></div>
|
|
{#if canManage}<details><summary>Advanced: download or verify these files</summary><p class="muted">Retry a download or prepare a previous upload on a host. The latest files are downloaded automatically.</p><div class="download"><label>Host<select bind:value={targets[item.id]}><option value="">Select host</option>{#each onlineHosts as host}<option value={host.id}>{host.name} · {formatGameBytes(Number(host.inventory.storageAvailable ?? 0))} free</option>{/each}</select></label><Button size="sm" disabled={busy || !targets[item.id]} onclick={() => download(item.id)}>Download / verify</Button></div></details>{/if}
|
|
{/snippet}
|
|
|
|
<style>
|
|
.page, .card, .uploads, .upload-entry, form { display:grid; gap:20px; }
|
|
.card { padding:24px; min-width:0; border:1px solid var(--border-subtle); background:var(--surface); border-radius:var(--radius-md); }
|
|
.uploads, .upload-entry { padding-top:20px; border-top:1px solid var(--border-subtle); }
|
|
.history>p, .history>.upload-entry { margin-top:16px; }
|
|
.heading, .download, .upload-actions { display:flex; gap:16px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
|
|
.download, .upload-actions { justify-content:flex-start; align-items:end; }
|
|
label { display:grid; gap:6px; font-size:var(--text-sm); }
|
|
input, select { width:100%; min-width:0; box-sizing:border-box; padding:10px; background:var(--surface); color:inherit; border:1px solid var(--border-strong); border-radius:var(--radius-sm); }
|
|
input:disabled, select:disabled { opacity:.7; } input[type='file'] { padding:16px; cursor:pointer; }
|
|
.folder-summary,.upload-progress { padding:20px; display:grid; gap:12px; background:var(--surface-sunken); border:1px solid var(--border-subtle); border-radius:var(--radius-md); min-width:0; }
|
|
.folder-summary>span,.upload-progress span,.upload-actions>span { color:var(--text-secondary); font-size:var(--text-sm); }
|
|
.path-list { padding-left:20px; font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; }
|
|
.current-file { font-family:var(--font-mono); font-size:var(--text-xs); overflow-wrap:anywhere; } progress { width:100%; accent-color:var(--accent-primary); }
|
|
h2,h3,p { margin:0; } h2 { font-size:var(--text-md); } h3 { font-size:var(--text-sm); }
|
|
p,td,th,details { font-size:var(--text-sm); line-height:1.6; } .muted { color:var(--text-secondary); }
|
|
a { color:var(--accent-primary-hover); } summary { cursor:pointer; }
|
|
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 16px; } dd { margin:0; overflow-wrap:anywhere; }
|
|
.table-wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; text-align:left; } th,td { padding:10px; border-bottom:1px solid var(--border-subtle); }
|
|
@media(max-width:640px) { .card,.folder-summary,.upload-progress { padding:16px; } dl { grid-template-columns:1fr; } .heading>div { min-width:0; } }
|
|
</style>
|