884 lines
36 KiB
Svelte
884 lines
36 KiB
Svelte
<script lang="ts">
|
|
import Select from '$ui/components/primitives/Select.svelte';
|
|
import Field from '$ui/components/primitives/Field.svelte';
|
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
|
import { gameTitle } from '$ui/server-labels.js';
|
|
import BackLink from '$ui/components/BackLink.svelte';
|
|
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 GameFileHosts from '$ui/components/GameFileHosts.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' : 'Black Ops II');
|
|
|
|
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 = `${gameTitle(game, true)} 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="Verified game files and host readiness." />
|
|
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/hosts" />{/if}
|
|
{#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'
|
|
? `${gameTitle(game, true)} files uploaded`
|
|
: uploadId
|
|
? `Continue ${gameTitle(game, true)} upload`
|
|
: `Upload ${gameTitle(game, true)} 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. Running servers
|
|
keep using their existing files.
|
|
</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 ${gameTitle(game, true)} 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>
|
|
{#if latest}<p class="muted">
|
|
{formatGameBytes(latest.sizeBytes)} · {latest.fileCount.toLocaleString()}
|
|
files
|
|
</p>{/if}
|
|
</div>
|
|
{#if canManage}<Button
|
|
size="sm"
|
|
disabled={uploadBusy}
|
|
onclick={() => newUpload(library)}
|
|
>{latest
|
|
? `Update ${gameTitle(library.game, true)} files`
|
|
: `Upload ${gameTitle(library.game, true)} files`}</Button
|
|
>{/if}
|
|
</div>
|
|
{#if latest}
|
|
<div class="heading">
|
|
<span class="muted">Latest verified upload</span><Badge
|
|
variant={latest.caches.some((cache) => cache.status === 'failed')
|
|
? 'danger'
|
|
: latest.caches.length &&
|
|
latest.caches.every((cache) => cache.status === 'ready')
|
|
? 'online'
|
|
: 'neutral'}
|
|
>{latest.caches.length
|
|
? `${latest.caches.filter((cache) => cache.status === 'ready').length} / ${latest.caches.length} host copies ready`
|
|
: 'No hosts'}</Badge
|
|
>
|
|
</div>
|
|
<GameFileHosts caches={latest.caches} {hosts} latest />
|
|
{:else}
|
|
<p class="muted">
|
|
Upload the complete {gameName(library.game)} 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 <RelativeTime value={item.createdAt} /> · {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 latest || history.length}<details class="library-details">
|
|
<summary>Details</summary>
|
|
<div class="detail-content">
|
|
{#if latest}<p class="muted">
|
|
Uploaded <RelativeTime
|
|
value={latest.publishedAt ?? latest.createdAt}
|
|
/>
|
|
</p>
|
|
{@render uploadDetails(latest)}{@render downloadControls(latest)}{/if}
|
|
{#if history.length}
|
|
<details class="history">
|
|
<summary>Previous uploads ({history.length})</summary>
|
|
<p class="muted">
|
|
Old host copies are removed once no instances use them.
|
|
Controller files are removed after all host copies are gone;
|
|
upload history is kept.
|
|
</p>
|
|
{#each history as item (item.id)}
|
|
<article class="upload-entry">
|
|
<h3>
|
|
Uploaded <RelativeTime
|
|
value={item.publishedAt ?? item.createdAt}
|
|
/>
|
|
</h3>
|
|
{#if item.filesRemoved}<p class="muted">
|
|
Files removed from controller · upload history
|
|
retained
|
|
</p>{:else if item.filesRemoving}<p class="muted">
|
|
Removing unused controller files · upload history
|
|
retained
|
|
</p>{/if}
|
|
<p>
|
|
{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()}
|
|
files
|
|
</p>
|
|
{@render uploadDetails(item)}
|
|
<details>
|
|
<summary>Host copies</summary><GameFileHosts
|
|
caches={item.caches}
|
|
{hosts}
|
|
/>{@render downloadControls(item)}
|
|
</details>
|
|
</article>
|
|
{/each}
|
|
</details>
|
|
{/if}
|
|
</div>
|
|
</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 downloadControls(item: OverviewLibrary['versions'][number])}
|
|
{#if canManage && !item.filesRemoved && !item.filesRemoving}<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">
|
|
<Field 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
|
|
></Field
|
|
><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: 16px;
|
|
}
|
|
.detail-content {
|
|
display: grid;
|
|
gap: 16px;
|
|
padding-top: 16px;
|
|
}
|
|
.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 {
|
|
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 {
|
|
opacity: 0.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,
|
|
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;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.card,
|
|
.folder-summary,
|
|
.upload-progress {
|
|
padding: 16px;
|
|
}
|
|
dl {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.heading > div {
|
|
min-width: 0;
|
|
}
|
|
}
|
|
</style>
|