feat(ui:delta upload): warn user if refresh page during upload
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user