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 { hashUrlToPath } from '$lib/utils/hashRedirect';
|
||||||
import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker';
|
import { killLegacyServiceWorker } from '$lib/utils/killLegacyServiceWorker';
|
||||||
import { getOidcProviders, type OidcProviders } from '$lib/api/endpoints/auth';
|
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();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -156,6 +158,32 @@
|
|||||||
// loading state) is already in the DOM behind it.
|
// loading state) is already in the DOM behind it.
|
||||||
document.getElementById('app-splash')?.remove();
|
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.
|
// Redirect old `#/...` bookmarks to the new path before anything else.
|
||||||
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
|
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
|
||||||
// hashUrlToPath returns a dynamic in-app path string; resolve() is typed
|
// hashUrlToPath returns a dynamic in-app path string; resolve() is typed
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
resolveOwnedHashes,
|
resolveOwnedHashes,
|
||||||
tryDeltaUpload
|
tryDeltaUpload
|
||||||
} from '$lib/api/endpoints/deltaUpload';
|
} from '$lib/api/endpoints/deltaUpload';
|
||||||
|
import {
|
||||||
|
acquireUploadGuard,
|
||||||
|
markUploadFinished,
|
||||||
|
markUploadStarted,
|
||||||
|
releaseUploadGuard
|
||||||
|
} from '$lib/upload/interruption';
|
||||||
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||||
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||||
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
||||||
@@ -710,6 +716,17 @@
|
|||||||
async function uploadBatch(files: File[]) {
|
async function uploadBatch(files: File[]) {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
uploading = true;
|
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(
|
const nid = ui.startProgress(
|
||||||
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
|
t('files.uploading_n', { done: 0, total: files.length }, `Uploading 0/${files.length} files…`)
|
||||||
);
|
);
|
||||||
@@ -760,6 +777,8 @@
|
|||||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
|
markUploadFinished(batchHandle);
|
||||||
|
releaseUploadGuard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1539,6 +1558,14 @@
|
|||||||
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
async function uploadTree(entries: { file: File; relativePath: string }[]) {
|
||||||
if (entries.length === 0) return;
|
if (entries.length === 0) return;
|
||||||
uploading = true;
|
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
|
// Same bell progress notification as uploadBatch, so folder uploads show
|
||||||
// live progress + a final result instead of staying silent until the end.
|
// live progress + a final result instead of staying silent until the end.
|
||||||
const nid = ui.startProgress(
|
const nid = ui.startProgress(
|
||||||
@@ -1594,6 +1621,8 @@
|
|||||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
uploading = false;
|
uploading = false;
|
||||||
|
markUploadFinished(treeHandle);
|
||||||
|
releaseUploadGuard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user