diff --git a/frontend/src/lib/upload/interruption.ts b/frontend/src/lib/upload/interruption.ts new file mode 100644 index 00000000..7ad7dec5 --- /dev/null +++ b/frontend/src/lib/upload/interruption.ts @@ -0,0 +1,128 @@ +/** + * Interrupted-upload registry — small helper so a page reload during + * upload doesn't leave the user without a hint that work was in flight. + * + * Two coordinated behaviours: + * + * 1. `beforeunload` warning while any upload is active. + * Registered lazily: as soon as an upload starts we install a + * page-scope handler that triggers the browser's "Leave site? + * Changes may not be saved" prompt on refresh / tab close. + * Deliberate leave (user clicks Leave) proceeds normally. + * + * 2. `sessionStorage`-backed "interrupted uploads" register. + * Every `uploadBatch` writes a record while it runs, clears it on + * completion. If a reload happens mid-flight, the record survives + * into the next page load and `readAndClearInterrupted` surfaces + * it so the layout can toast: "Uploads were interrupted — re-drop + * the files to resume (already-uploaded chunks reuse)." + * + * `sessionStorage` (not `localStorage`) on purpose: entries clear + * when the tab closes entirely, so a "closed the tab an hour ago" + * user isn't nagged. Only a same-tab reload preserves them. + */ + +/** Key under which the interrupted-uploads register is stored. */ +const STORAGE_KEY = 'oxi:upload:interrupted'; + +/** One record per active `uploadBatch()` invocation. */ +export interface InterruptedRecord { + /** UI-facing description — filename for singleton uploads, "N files" for batches. */ + description: string; + /** Where the batch was targeted. `null` = drive root. */ + folderId: string | null; + /** UNIX ms — used as the identity key that matches start/finish + * calls so multiple concurrent batches don't step on each other. */ + startedAt: number; +} + +// ── Page-scope beforeunload guard ──────────────────────────────────── + +let activeBatches = 0; + +function beforeUnloadHandler(e: BeforeUnloadEvent): void { + // Spec-compliant trigger for the browser's "Leave site?" dialog. + // Modern Chrome/Firefox/Safari all honor preventDefault(). The + // browser shows its own confirmation copy — we can't customize it. + e.preventDefault(); +} + +/** Register a live upload batch so the beforeunload guard is active while + * it runs. Balanced by `releaseUploadGuard` in the batch's finally. */ +export function acquireUploadGuard(): void { + if (typeof window === 'undefined') return; + if (activeBatches === 0) { + window.addEventListener('beforeunload', beforeUnloadHandler); + } + activeBatches++; +} + +/** Match to `acquireUploadGuard`; when the last active batch releases, + * the beforeunload listener is removed so unrelated navigations don't + * trigger the browser's "leave site?" prompt. */ +export function releaseUploadGuard(): void { + if (typeof window === 'undefined') return; + activeBatches = Math.max(0, activeBatches - 1); + if (activeBatches === 0) { + window.removeEventListener('beforeunload', beforeUnloadHandler); + } +} + +// ── sessionStorage register ────────────────────────────────────────── + +function readAll(): InterruptedRecord[] { + if (typeof sessionStorage === 'undefined') return []; + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as InterruptedRecord[]) : []; + } catch { + return []; + } +} + +function writeAll(records: InterruptedRecord[]): void { + if (typeof sessionStorage === 'undefined') return; + try { + if (records.length === 0) sessionStorage.removeItem(STORAGE_KEY); + else sessionStorage.setItem(STORAGE_KEY, JSON.stringify(records)); + } catch { + /* quota exceeded / disabled — silent; the register is best-effort. */ + } +} + +/** Add a record when a batch starts. Returns a handle to pass back to + * `markUploadFinished` on completion / failure — that way multiple + * concurrent batches don't step on each other's entries. */ +export function markUploadStarted(description: string, folderId: string | null): number { + const record: InterruptedRecord = { + description, + folderId, + startedAt: Date.now() + }; + const all = readAll(); + all.push(record); + writeAll(all); + return record.startedAt; +} + +/** Remove the record when the batch completes (success or failure). Uses + * the `markUploadStarted` return value as the identity key. */ +export function markUploadFinished(startedAt: number): void { + const all = readAll(); + const idx = all.findIndex((r) => r.startedAt === startedAt); + if (idx >= 0) { + all.splice(idx, 1); + writeAll(all); + } +} + +/** Read and clear the register — called by the layout on mount. Returns + * what was there. Empties the register in the same call so a subsequent + * refresh doesn't re-notify. */ +export function readAndClearInterrupted(): InterruptedRecord[] { + const all = readAll(); + writeAll([]); + return all; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index e0f6f83a..3209bf75 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -15,6 +15,8 @@ import { hashUrlToPath } from '$lib/utils/hashRedirect'; import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker'; import { getOidcProviders, type OidcProviders } from '$lib/api/endpoints/auth'; + import { readAndClearInterrupted } from '$lib/upload/interruption'; + import { t } from '$lib/i18n/index.svelte'; let { children } = $props(); @@ -156,6 +158,32 @@ // loading state) is already in the DOM behind it. document.getElementById('app-splash')?.remove(); + // If a page reload interrupted one or more uploads mid-flight, the + // registry in sessionStorage carries breadcrumbs into the next mount. + // Toast a resume hint — already-uploaded chunks reuse via delta's + // negotiate stage, so re-dropping the same file is fast, not from + // scratch. Reading the register clears it so a subsequent reload + // doesn't re-notify. + const interrupted = readAndClearInterrupted(); + if (interrupted.length > 0) { + const one = interrupted.length === 1; + const msg = one + ? t( + 'files.upload_interrupted_one', + { description: interrupted[0].description }, + `Upload interrupted: ${interrupted[0].description}. Re-drop to resume — already-uploaded chunks are reused.` + ) + : t( + 'files.upload_interrupted_many', + { n: interrupted.length }, + `${interrupted.length} uploads interrupted. Re-drop the files to resume — already-uploaded chunks are reused.` + ); + // 10 s dwell — same pattern as the other long-copy toasts + // (see profile SSO error). Info kind because the situation is + // recoverable and the user only needs to know how to resume. + ui.notify(msg, 'info', 10000); + } + // Redirect old `#/...` bookmarks to the new path before anything else. if (typeof location !== 'undefined' && location.hash.startsWith('#/')) { // hashUrlToPath returns a dynamic in-app path string; resolve() is typed diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 9c5f69cc..fc8ca3f4 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -30,6 +30,12 @@ resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload'; + import { + acquireUploadGuard, + markUploadFinished, + markUploadStarted, + releaseUploadGuard + } from '$lib/upload/interruption'; import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; @@ -710,6 +716,17 @@ async function uploadBatch(files: File[]) { if (files.length === 0) return; uploading = true; + // Arm the reload-guard + persist a "batch in flight" marker so a + // page refresh mid-upload (a) prompts the browser's "Leave site?" + // dialog and (b) leaves a breadcrumb the layout picks up on the + // next mount → toasts a "uploads were interrupted, re-drop to + // resume (chunks reuse)" hint. + acquireUploadGuard(); + const batchDescription = + files.length === 1 + ? files[0].name + : t('files.n_files_batch', { n: files.length }, `${files.length} files`); + const batchHandle = markUploadStarted(batchDescription, currentId); const nid = ui.startProgress( t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`) ); @@ -760,6 +777,8 @@ ui.finishProgress(nid, errorMessage(err), 'error'); } finally { uploading = false; + markUploadFinished(batchHandle); + releaseUploadGuard(); } } @@ -1539,6 +1558,14 @@ async function uploadTree(entries: { file: File; relativePath: string }[]) { if (entries.length === 0) return; uploading = true; + // Same reload-guard + interrupted-uploads breadcrumb as uploadBatch — + // the browser prompts on refresh, and if the user reloads anyway + // the layout picks up the marker on next mount and toasts. + acquireUploadGuard(); + const treeHandle = markUploadStarted( + t('files.n_files_batch', { n: entries.length }, `${entries.length} files`), + currentId + ); // Same bell progress notification as uploadBatch, so folder uploads show // live progress + a final result instead of staying silent until the end. const nid = ui.startProgress( @@ -1594,6 +1621,8 @@ ui.finishProgress(nid, errorMessage(err), 'error'); } finally { uploading = false; + markUploadFinished(treeHandle); + releaseUploadGuard(); } }