diff --git a/frontend/src/lib/components/EmptyState.svelte b/frontend/src/lib/components/EmptyState.svelte index 4403103f..1e0c606b 100644 --- a/frontend/src/lib/components/EmptyState.svelte +++ b/frontend/src/lib/components/EmptyState.svelte @@ -18,7 +18,19 @@ let { icon, title, hint, error = false, children }: Props = $props(); -
+ +
{#if icon}{/if} {#if title}

{title}

{/if} {#if hint}

{hint}

{/if} diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 3a80c6e0..919a3770 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -1882,13 +1882,17 @@ row.kind === 'personal' ? t('admin.quota_personal', 'Personal drives') : t('admin.quota_shared', 'Shared drives')} + {@const total = row.unlimited_count + row.capped_count} {@const pct = row.capped_quota_bytes && row.capped_quota_bytes > 0 ? (row.used_bytes / row.capped_quota_bytes) * 100 : null} {#if row.capped_count > 0 || row.unlimited_count > 0} - {label} + + {total} + {label} + {#if row.capped_quota_bytes !== null && pct !== null} {formatBytes(row.used_bytes)} / {formatBytes(row.capped_quota_bytes)} @@ -4569,6 +4573,19 @@ white-space: nowrap; } + /* Prepended drive count: tabular-nums so single/double/triple digits align + vertically across rows; right-aligned inside a fixed-width box so the + ones-digits line up across "personal" and "shared" rows regardless of + how many digits each count has. */ + .quota-table__count { + display: inline-block; + min-width: 1.5em; + margin-right: 0.25em; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text-heading); + } + .quota-table__num { font-variant-numeric: tabular-nums; white-space: nowrap; diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index fc8ca3f4..e4d9056f 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -713,8 +713,43 @@ * Upload a batch of files into the current folder, reporting aggregate * progress through a single bell notification with a progress bar. */ + /** + * Cold-navigation upload guard. + * + * `currentId` starts `null` and is only populated inside `load()` AFTER + * `session.loadHomeFolder()` resolves (see the `$effect` at the bottom of + * this file that drives `load()`, and the assignment at `currentId = + * folderId` inside `load()`). The hidden `` is unconditional in the template, so it's + * in the DOM the moment the page shell mounts — before `load()` has + * awaited its first HTTP round-trip. + * + * On a slow network / cold page / Playwright cold `page.goto` immediately + * followed by `setInputFiles`, `onchange` can fire while `currentId` is + * still `null`. Without this guard, `uploadBatch` / `uploadTree` post + * with `folderId: null` and the file silently lands in the caller's + * home root instead of the intended folder — a real user hitting Ctrl+U + * or dropping a file within ~100 ms of navigation hits the same window. + * + * The e2e reproduction: `tests/e2e/spa/files.spec.ts::"upload a file via + * the hidden file input"` flakes on CI where the mount→load round-trip + * outruns Playwright's file-input dispatch. + * + * Returns `true` when it's safe to proceed; `false` + a user-visible + * toast when the folder isn't ready. + */ + function guardUploadFolderReady(): boolean { + if (currentId !== null) return true; + ui.notify( + t('files.upload_folder_not_ready', 'Folder is still loading — please try again in a moment.'), + 'warning' + ); + return false; + } + async function uploadBatch(files: File[]) { if (files.length === 0) return; + if (!guardUploadFolderReady()) 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?" @@ -1557,6 +1592,7 @@ */ async function uploadTree(entries: { file: File; relativePath: string }[]) { if (entries.length === 0) return; + if (!guardUploadFolderReady()) return; uploading = true; // Same reload-guard + interrupted-uploads breadcrumb as uploadBatch — // the browser prompts on refresh, and if the user reloads anyway diff --git a/frontend/src/routes/files/page.test.ts b/frontend/src/routes/files/page.test.ts index b2c78a27..059dc70d 100644 --- a/frontend/src/routes/files/page.test.ts +++ b/frontend/src/routes/files/page.test.ts @@ -158,6 +158,12 @@ it('keeps aggregate upload progress exact when one file restarts', async () => { ); render(FilesPage); const input = await screen.findByTestId('files-upload-file-input'); + // The cold-navigation upload guard (`guardUploadFolderReady` in + // `+page.svelte`) refuses uploads while `currentId` is null — which is + // the initial state before `load()` runs. `load()` sets `currentId = + // folderId` BEFORE it calls `fetchFolderPage`, so waiting on the fetch + // mock is a stable "load() has progressed past the assignment" signal. + await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]; Object.defineProperty(input, 'files', { configurable: true, value: uploads }); diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 4058be85..fc20b4d3 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -546,6 +546,7 @@ "empty_hidden_hint": "Files whose name starts with '.' are hidden. Toggle the setting to see them.", "show_hidden": "Show hidden files", "upload_dotfile_hidden": "{{n}} file(s) uploaded but hidden by your dotfile preference.", + "upload_folder_not_ready": "Folder is still loading — please try again in a moment.", "rename_dotfile_hidden": "Renamed to '{{name}}' — now hidden by your preference.", "new_folder_dotfile_hidden": "Created folder '{{name}}' — hidden by your dotfile preference.", "dotfiles_hidden_toast": "Dotfiles hidden", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index d3b6713f..a2ef3ce1 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -459,6 +459,7 @@ "empty_hidden_hint": "Les fichiers dont le nom commence par '.' sont masqués. Modifiez le réglage pour les afficher.", "show_hidden": "Afficher les fichiers masqués", "upload_dotfile_hidden": "{{n}} fichier(s) téléversé(s) mais masqué(s) par votre préférence.", + "upload_folder_not_ready": "Le dossier est encore en cours de chargement — merci de réessayer dans un instant.", "rename_dotfile_hidden": "Renommé en \"{{name}}\" — désormais masqué par votre préférence.", "new_folder_dotfile_hidden": "Dossier \"{{name}}\" créé — masqué par votre préférence.", "dotfiles_hidden_toast": "Fichiers masqués", diff --git a/tests/e2e/spa/files.spec.ts b/tests/e2e/spa/files.spec.ts index ef7e88f5..467a8140 100644 --- a/tests/e2e/spa/files.spec.ts +++ b/tests/e2e/spa/files.spec.ts @@ -74,7 +74,16 @@ test('upload a file via the hidden file input', async ({ page }) => { // Navigate straight into the (empty) folder by ID (the route keys on folder // id, not name) — avoids the crowded root listing and click ambiguity. await page.goto(`/files/${created.id}`); - await expect(page.getByTestId('files-upload-file-input')).toBeAttached({ timeout: 15_000 }); + // The hidden file input renders unconditionally on mount, so waiting on + // `toBeAttached` fires BEFORE the page's `load()` populates `currentId` + // from the URL. Firing `setInputFiles` in that window used to race + // `load()` and post the upload with `folderId: null`, silently landing + // the file in the caller's home root — the guard in + // `guardUploadFolderReady` now refuses that upload with a toast. Wait + // for the empty-state hook instead — `ResourceList` only renders + // `EmptyState` once `load()` has definitively completed with zero + // items, so it doubles as a "folder is ready to accept uploads" signal. + await expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 15_000 }); // Now inside the folder; upload a text file by setting the hidden input. const f = SAMPLE_FILES.text(); await page.getByTestId('files-upload-file-input').setInputFiles({