From 75ae837f1a4c07f0f6b47913a27cb0271a7b6347 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 19 Jul 2026 23:58:26 +0200 Subject: [PATCH 01/21] refator(ui): merge of ResourceList part1 --- frontend/src/lib/components/ActionBar.svelte | 37 +++ .../lib/components/DisplayModeControls.svelte | 194 ++++++++++++ .../src/lib/components/ResourceList.svelte | 298 ++++++++++++++---- frontend/src/routes/favorites/+page.svelte | 5 +- frontend/src/routes/recent/+page.svelte | 7 +- frontend/src/routes/trash/+page.svelte | 34 +- frontend/static/locales/ar.json | 7 +- frontend/static/locales/de.json | 7 +- frontend/static/locales/en.json | 7 +- frontend/static/locales/es.json | 7 +- frontend/static/locales/fa.json | 7 +- frontend/static/locales/fr.json | 7 +- frontend/static/locales/hi.json | 7 +- frontend/static/locales/it.json | 7 +- frontend/static/locales/ja.json | 7 +- frontend/static/locales/ko.json | 7 +- frontend/static/locales/nl.json | 7 +- frontend/static/locales/pl.json | 7 +- frontend/static/locales/pt.json | 7 +- frontend/static/locales/ru.json | 7 +- frontend/static/locales/zh-TW.json | 7 +- frontend/static/locales/zh.json | 7 +- 22 files changed, 598 insertions(+), 89 deletions(-) create mode 100644 frontend/src/lib/components/ActionBar.svelte create mode 100644 frontend/src/lib/components/DisplayModeControls.svelte diff --git a/frontend/src/lib/components/ActionBar.svelte b/frontend/src/lib/components/ActionBar.svelte new file mode 100644 index 00000000..45812645 --- /dev/null +++ b/frontend/src/lib/components/ActionBar.svelte @@ -0,0 +1,37 @@ + + +
+ {#if start}{@render start()}{:else}
{/if} + {#if end}{@render end()}{/if} +
diff --git a/frontend/src/lib/components/DisplayModeControls.svelte b/frontend/src/lib/components/DisplayModeControls.svelte new file mode 100644 index 00000000..e2582da5 --- /dev/null +++ b/frontend/src/lib/components/DisplayModeControls.svelte @@ -0,0 +1,194 @@ + + + + +{#if anyVisible} +
+ {#if beforeGroupBy}{@render beforeGroupBy()}{/if} + {#if groups?.length} +
+ + {#if sortVisible} + + {/if} + {#if menuOpen} +
+ {#each groups as g (g.key)} + + {/each} +
+ {/if} +
+ {#if showViewMode}{/if} + {/if} + {#if showViewMode} + + + {/if} + {#if showDotfileToggle} + + {/if} +
+{/if} diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 9818c4bc..6ec3ceae 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -69,10 +69,12 @@ import Icon from '$lib/icons/Icon.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import SkeletonList from '$lib/components/SkeletonList.svelte'; - import ListToolbar from '$lib/components/ListToolbar.svelte'; + import ActionBar from '$lib/components/ActionBar.svelte'; + import DisplayModeControls from '$lib/components/DisplayModeControls.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { ui } from '$lib/stores/ui.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { preferences } from '$lib/stores/preferences.svelte'; import { formatBytes } from '$lib/utils/format'; @@ -176,10 +178,62 @@ onfavorite?: (item: FileItem | FolderItem) => void; /** Selection changed (set of selected item ids). */ onselectionchange?: (ids: Set) => void; - actions?: Snippet<[FileItem | FolderItem]>; - toolbar?: Snippet; - /** Batch toolbar shown when items are selected; receives selected items. */ - batchToolbar?: Snippet<[Array]>; + /** + * Per-item action cell (renders at the end of a row). Kept as a + * distinct slot from the action-bar snippets below so callers + * that want an item-scoped affordance (a per-row overflow menu) + * don't have to piggyback on the bar. + */ + itemActions?: Snippet<[FileItem | FolderItem]>; + /** + * Action-bar left cluster — always-visible page action buttons + * (Upload / New folder / Empty trash / Clear recent / …). Swaps + * to `batchActions` when the selection is non-empty. Every + * section provides its own buttons; ResourceList doesn't ship + * any defaults. + */ + actions?: Snippet; + /** + * Action-bar left cluster when selection is non-empty — + * replaces `actions`. Receives the selected items so buttons + * can be scoped to the batch. Replaces the phase-1 + * `batchToolbar` floating strip pattern. + */ + batchActions?: Snippet<[Array]>; + /** + * Rendered next to the item name in each row. `/trash` uses + * this for its expiration badge; other sections omit it. + * ResourceList stays ignorant of what the badge means — the + * page decides. Empty return = no badge. + */ + rowBadge?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>; + /** + * Rendered above the toolbar in the sticky header. Only + * `/files` wires this today; every other section leaves the + * snippet undefined so no breadcrumb strip appears. Kept as a + * snippet (not a boolean) so the page owns crumb rendering and + * their click / drag-drop behavior. + */ + breadcrumb?: Snippet; + /** + * When true, drops from the OS file system on the ResourceList + * wrapper are forwarded to `onsystemdrop` (upload path). When + * false (default), the wrapper still intercepts the OS drop — + * `preventDefault` so the browser doesn't navigate to the file + * — and fires a "wrong section" `ui.notify()` pointing the user + * at the Files section (the legacy behaviour). Item-drag drops + * (row → folder) are unaffected either way; those go through + * `onitemdrop` per the existing row hooks. + */ + enableSystemDrop?: boolean; + /** + * Called with the OS-dropped files when `enableSystemDrop` is + * true. The page keeps ownership of the upload code (walking + * webkitGetAsEntry trees, chunked uploader, etc.) — this + * component just delivers the payload. Ignored when + * `enableSystemDrop` is false. + */ + onsystemdrop?: (e: DragEvent) => void; /** * Render `` thumbnails on file rows and fall back to * client-side generation when the server doesn't have one @@ -266,9 +320,13 @@ onopen, onfavorite, onselectionchange, + itemActions, actions, - toolbar, - batchToolbar, + batchActions, + rowBadge, + breadcrumb, + enableSystemDrop = false, + onsystemdrop, enableThumbnails = true, isDraggable, isDropTarget, @@ -345,7 +403,7 @@ showType ? '120px' : '', showSize ? '110px' : '', showDate ? '160px' : '', - actions ? '120px' : '' + itemActions ? '120px' : '' ] .filter(Boolean) .join(' ') @@ -562,6 +620,63 @@ .filter(Boolean) .join('\n'); } + + // ── System-drop handling (OS files onto the wrapper) ─────────────────────── + // Two modes: + // + // * `enableSystemDrop = true`: the page has an upload code path + // ready (the `/files` browser). We `preventDefault` the browser's + // default (which would open the dragged file as a top-level + // navigation), highlight the drop zone, and hand the DragEvent + // off to the page via `onsystemdrop`. The page walks the entries + // (webkitGetAsEntry / DataTransferItemList) and drives the upload. + // + // * `enableSystemDrop = false` (default): the page has no upload + // path. Still `preventDefault` so the browser doesn't navigate + // away, but instead of forwarding, fire a `ui.notify()` that + // points the user at `/files` — this restores the legacy vanilla + // frontend's "wrong drop zone" behaviour so users don't wonder + // why their drag was silently ignored. + // + // Row-scoped drops (dragging an in-app row onto a folder row / the + // breadcrumb) are handled by the existing `onitemdrop` hooks and use + // a private `application/x-oxi-item` MIME so the `Files` type check + // below never matches them. + let systemDropOver = $state(false); + function isSystemDrag(e: DragEvent): boolean { + return !!e.dataTransfer?.types?.includes('Files'); + } + function onSystemDragEnter(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + systemDropOver = true; + } + function onSystemDragOver(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none'; + } + function onSystemDragLeave(e: DragEvent) { + if (!isSystemDrag(e)) return; + systemDropOver = false; + } + function onSystemDrop(e: DragEvent) { + if (!isSystemDrag(e)) return; + e.preventDefault(); + systemDropOver = false; + if (enableSystemDrop && onsystemdrop) { + onsystemdrop(e); + } else if (!enableSystemDrop) { + ui.notify( + t( + 'resource_list.wrong_drop_zone_msg', + 'Uploads only work in Files — open the Files section and drop there.' + ), + 'warning', + 6000 + ); + } + } {#snippet row(item: FileItem | FolderItem)} @@ -644,6 +759,7 @@ {/if} {item.name} + {#if rowBadge}{@render rowBadge(item, ctx)}{/if} {#if showOwner}
@@ -687,51 +803,83 @@ }}> {/if} - {#if actions} -
{@render actions(item)}
+ {#if itemActions} +
{@render itemActions(item)}
{/if}
{/snippet} + + +

{title}

- + {#if breadcrumb} +
{@render breadcrumb()}
+ {/if} + {#snippet start()} -
{@render toolbar?.()}
+
+ + {#if selectable && selected.size > 0 && batchActions} + + {t('files.selected_count', { count: selected.size }, '{{count}} selected')} + {@render batchActions(selectedItems)} + {:else if actions} + {@render actions()} + {/if} +
{/snippet} -
+ {#snippet end()} + + {/snippet} +
-{#if selectable && selected.size > 0 && batchToolbar} -
- - {t('files.selected_count', { count: selected.size }, '{{count}} selected')} -
{@render batchToolbar(selectedItems)}
-
-{/if} - {#if error} {:else if loading && isEmpty} @@ -822,6 +970,7 @@
{/if} + {#snippet listHeader()}
@@ -842,7 +991,7 @@ {#if showType}
{t('files.col_type', 'Type')}
{/if} {#if showSize}
{t('files.col_size', 'Size')}
{/if} {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if onfavorite || actions}
{/if} + {#if onfavorite || itemActions}
{/if}
{/snippet} @@ -889,19 +1038,47 @@ width: 100%; } - /* ── Batch toolbar ── */ - .rl-batch { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-4); - margin-bottom: var(--space-3); - background: var(--color-accent-bg); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); + /* ── OS-drop wrapper ── + `.rl-root` catches drops that miss a specific in-app drop target + (row → folder). Its highlight fires ONLY when + `enableSystemDrop && dragging` — the "wrong drop zone" toast path + deliberately leaves the surface unhighlighted so users don't get a + false accept cue. */ + .rl-root { + position: relative; } - .rl-batch__close { + .rl-root--drop-over::after { + content: ''; + position: absolute; + inset: 0; + border: 2px dashed var(--color-accent); + border-radius: var(--radius-md); + pointer-events: none; + } + + /* ── Breadcrumb strip inside the sticky header ── */ + .rl-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + min-height: 28px; + } + + /* ── Row badge (trash expiration, etc.) ── */ + .name-cell__badge { + display: inline-flex; + align-items: center; + margin-left: var(--space-2); + } + + /* ── Selection controls inside the action bar ── + Replaces the deprecated `.rl-batch` floating strip. When items + are selected, the close button + count sit before the page's + `batchActions` snippet inside `.action-buttons`, so the whole + cluster reads as one row of the action bar. */ + .rl-batch-close { display: inline-flex; align-items: center; justify-content: center; @@ -914,22 +1091,15 @@ cursor: pointer; } - .rl-batch__close:hover { + .rl-batch-close:hover { background: var(--color-bg-hover); } - .rl-batch__count { + .rl-batch-count { font-weight: var(--weight-semibold); color: var(--color-text); } - .rl-batch__actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-left: auto; - } - /* ── Selection column ── */ .select-cell { display: flex; diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index d19afdac..9daa62f0 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -254,7 +254,7 @@ ]; // ── Selection + batch ───────────────────────────────────────────────────── - // Selected items arrive via the batchToolbar snippet param — + // Selected items arrive via the batchActions snippet param — // ResourceList already derives them (O(selection), not O(N)); a // host-side `items.filter(...)` shadow would re-run a second full scan // per selection toggle, and its id mirror is unnecessary (the component @@ -307,6 +307,7 @@ onopen={open} onfavorite={unfavorite} showOwner + showPath selectable {contextActions} {groupBys} @@ -317,7 +318,7 @@ load(true, orderBy, rev); }} > - {#snippet batchToolbar(sel)} + {#snippet batchActions(sel)} {/if} {/snippet} - {#snippet batchToolbar(sel)} + {#snippet batchActions(sel)} {/if} {/snippet} - {#snippet dateCell(_item, ctx)} + {#snippet batchActions(sel)} + + + {/snippet} + {#snippet rowBadge(_item, ctx)} {@const chip = expiryChip(ctx?.date)} {chip.label} {/snippet} + {#snippet dateCell(_item, ctx)} + {formatDate(ctx?.date)} + {/snippet} {#snippet bucketAction(bucketKey: string)} {#if showPerDriveEmpty} {@const driveId = driveIdFromBucketKey(bucketKey)} @@ -310,7 +336,7 @@ {/if} {/if} {/snippet} - {#snippet actions(item)} + {#snippet itemActions(item)} - {/if} - {#if itemActions} -
{@render itemActions(item)}
+ + {#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length} +
+ {#if onfavorite} + + {/if} + {#if itemActions}{@render itemActions(item)}{/if} + {#if onContextMenuOverride || contextActions?.length} + + {/if} +
{/if} {/snippet} @@ -836,20 +888,27 @@ {/if} {#snippet start()} -
- + +
0 && batchActions} + > {#if selectable && selected.size > 0 && batchActions} - {t('files.selected_count', { count: selected.size }, '{{count}} selected')} - {@render batchActions(selectedItems)} +
+ {@render batchActions(selectedItems)} +
{:else if actions} {@render actions()} {/if} @@ -975,7 +1036,7 @@ {#snippet listHeader()}
{#if selectable} -
+
{t('files.col_type', 'Type')}
{/if} {#if showSize}
{t('files.col_size', 'Size')}
{/if} {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if onfavorite || itemActions}
{/if} + {#if hasActionCell}
{/if}
{/snippet} @@ -1073,51 +1134,6 @@ margin-left: var(--space-2); } - /* ── Selection controls inside the action bar ── - Replaces the deprecated `.rl-batch` floating strip. When items - are selected, the close button + count sit before the page's - `batchActions` snippet inside `.action-buttons`, so the whole - cluster reads as one row of the action bar. */ - .rl-batch-close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text-secondary); - cursor: pointer; - } - - .rl-batch-close:hover { - background: var(--color-bg-hover); - } - - .rl-batch-count { - font-weight: var(--weight-semibold); - color: var(--color-text); - } - - /* ── Selection column ── */ - .select-cell { - display: flex; - align-items: center; - justify-content: center; - } - - .file-item--selected { - background: var(--color-accent-bg); - } - - /* Drop-target highlight — mirrors the legacy files browser's cue when - dragging a row over a folder row. */ - .file-item--drop-target { - outline: 2px dashed var(--color-accent); - outline-offset: -2px; - } - /* ── Owner vignette ── */ .owner-cell { display: flex; @@ -1138,29 +1154,6 @@ white-space: nowrap; } - /* ── Favorite star ── */ - .rl-star { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: none; - border-radius: var(--radius-sm); - background: transparent; - color: var(--color-text-faint); - cursor: pointer; - } - - .rl-star:hover { - background: var(--color-bg-hover); - color: var(--color-text-secondary); - } - - .rl-star--on { - color: var(--color-warning-text-amber); - } - /* ── Swimlane section header ── */ .rl-swimlane-header { grid-column: 1 / -1; diff --git a/frontend/src/lib/styles/ported/batchToolbar.css b/frontend/src/lib/styles/ported/batchToolbar.css index 1f56c462..fe995745 100644 --- a/frontend/src/lib/styles/ported/batchToolbar.css +++ b/frontend/src/lib/styles/ported/batchToolbar.css @@ -21,13 +21,11 @@ margin-right: var(--space-3); height: 60px; transform: translateY(-8px); - transition: - opacity 0.2s, - max-height 0.25s, - transform 0.2s, - margin 0.2s, - padding 0.2s; pointer-events: auto; + /* Note: previous versions of this rule animated the bar's + appearance (opacity / max-height / transform / margin / padding + transitions on the class-add). Dropped intentionally — the bar + just appears/disappears with the selection state now. */ } .batch-bar-close { diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 50e61c3d..dd1708ca 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -45,9 +45,11 @@ import { countHidden, filterDotfiles } from '$lib/utils/dotfileFilter'; import { preferences } from '$lib/stores/preferences.svelte'; import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; - import ListToolbar from '$lib/components/ListToolbar.svelte'; import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte'; - import VirtualList from '$lib/components/VirtualList.svelte'; + import ResourceList, { + isFile, + type GroupByDef as RLGroupByDef + } from '$lib/components/ResourceList.svelte'; import { lazyComponent } from '$lib/composables/lazyComponent.svelte'; import { t } from '$lib/i18n/index.svelte'; import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte'; @@ -55,22 +57,8 @@ import { files as filesStore } from '$lib/stores/files.svelte'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; - import { - dateBucket, - ownerLabel, - relativeTimeAgo, - sizeBucket, - typeLabel - } from '$lib/stores/files.svelte'; - import { formatBytes } from '$lib/utils/format'; + import { dateBucket, sizeBucket, typeLabel } from '$lib/stores/files.svelte'; import { replaceSet } from '$lib/utils/sets'; - import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; - import { gridColumns } from '$lib/utils/grid'; - import { - canThumbnailClientSide, - preloadPdf, - queueGenerate as queueThumbnailGenerate - } from '$lib/utils/thumbnail'; // File preview and the WOPI editor are heavy and only appear on demand, so // their modules load the first time the user opens one (see the effects that @@ -152,7 +140,6 @@ let error = $state(null); let fileInput = $state(null); let uploading = $state(false); - let dragOver = $state(false); interface ActionTarget { id: string; @@ -696,7 +683,6 @@ async function onDrop(e: DragEvent) { e.preventDefault(); - dragOver = false; const dt = e.dataTransfer; if (!dt) return; // A dropped folder isn't expanded into `.files`, so walk the dropped entry @@ -882,63 +868,16 @@ } // ── Multi-select + batch ──────────────────────────────────────────────── - // In-place `SvelteSet`: a toggle is O(1) (no full-set copy) and spares - // the other selected rows' `has()` readers — decisive when refining a - // select-all (selectionPatterns.bench.test.ts). + // After the ResourceList migration the row-level selection UX (shift- + // range, ctrl-toggle, anchor tracking, header select-all) lives inside + // `` and mirrors state OUT via `onselectionchange`. This + // SvelteSet is the local reflection the batch action functions consume; + // it stays a plain in-place `SvelteSet` (per benches ROUND11 §S2) so + // batch buttons see the same set as the row template does. const selected = new SvelteSet(); - // Anchor row id for shift-click range selection. - let selectionAnchor = $state(null); - function toggleSelected(id: string) { - if (selected.has(id)) selected.delete(id); - else selected.add(id); - selectionAnchor = id; - } function clearSelection() { selected.clear(); - selectionAnchor = null; - } - - /** - * Row click selection mirroring static/js/components/resourceList.js: - * - Shift+click selects the contiguous range from the anchor to this row. - * - Ctrl/Cmd+click toggles this row without clearing the rest. - * - A plain click (no modifier) opens the item — handled by the caller. - * Returns true when the click was consumed as a selection gesture. - */ - function handleSelectionClick(e: MouseEvent, id: string): boolean { - if (e.shiftKey && selectionAnchor) { - e.preventDefault(); - const a = orderedIds.indexOf(selectionAnchor); - const b = orderedIds.indexOf(id); - if (a !== -1 && b !== -1) { - const [lo, hi] = a < b ? [a, b] : [b, a]; - for (let i = lo; i <= hi; i++) selected.add(orderedIds[i]); - } - return true; - } - if (e.ctrlKey || e.metaKey) { - e.preventDefault(); - toggleSelected(id); - return true; - } - return false; - } - - const selectedCount = $derived(selected.size); - const totalCount = $derived(visibleFolders.length + visibleFiles.length); - - function toggleSelectAll() { - if (selected.size === totalCount) { - clearSelection(); - } else { - // Select-all only picks what the user can see — dotfiles hidden - // by the current filter are excluded so "select all → delete" - // can't accidentally sweep up hidden files the user never saw. - selected.clear(); - for (const i of visibleFolders) selected.add(i.id); - for (const i of visibleFiles) selected.add(i.id); - } } /** @@ -1060,16 +999,18 @@ function onKeydown(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') { - e.preventDefault(); - toggleSelectAll(); - } else if (e.key === 'Escape' && selected.size) { + if (e.key === 'Escape' && selected.size) { clearSelection(); } else if (e.key === 'Delete' && selected.size) { // Delete only — Backspace was dropped: it triggered accidental deletes. e.preventDefault(); void batchDelete(); } + // Ctrl+A "select all" moved to the list-header checkbox owned by + // ResourceList — the row-level selection UX now lives entirely + // there, so the shortcut is served by clicking that box. Kept the + // binding surface here for the still-page-level Escape / Delete + // gestures that reference the local `selected` mirror. } async function batchDelete() { @@ -1488,134 +1429,113 @@ return v * sortDir; } - // `visibleFolders` / `visibleFiles` are declared up-top (near - // `listing`) because `totalCount` and `isEmpty` reference them - // before this block; only the sorted copies live here so they - // stay next to the sort comparators. + // Sorted (folders-then-files) merged into one `Array` + // that renders directly. Order matches the un-migrated + // layout: folders precede files, sort key applied within each cohort. The + // bespoke `Entry` discriminator + swimlane bucketing that used to live + // here is gone — ResourceList does swimlane bucketing itself via + // `rlGroupBys` below. const sortedFolders = $derived([...visibleFolders].sort(cmpFolders)); const sortedFiles = $derived([...visibleFiles].sort(cmpFiles)); + const rlItems = $derived>([...sortedFolders, ...sortedFiles]); - /** Flat id order matching how rows are displayed (folders then files). */ - const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]); + // Group-by state (bound to ). Kept as a `string` prop + // value; the current `sortField` mirrors from the picked group's + // `orderBy` so a group-by change also drives the sort. + let groupBy = $state(''); - // Folders-then-files as one ordered, discriminated list so the (flat) view can - // be windowed by a single VirtualList. Content width drives the grid columns. - type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem }; - const entries = $derived([ - ...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })), - ...sortedFiles.map((file) => ({ kind: 'file' as const, file })) - ]); - const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id); - let gridWidth = $state(0); - - // ── Group-by / swimlanes ───────────────────────────────────────────────── - // Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus - // Type / Size / Modified date / Created date dimensions. Folders always group - // into their own lane (Folder / "Folders" size sentinel) ahead of the files. - type GroupBy = '' | 'type' | 'size' | 'modifiedAt' | 'createdAt'; - let groupBy = $state(''); - - interface ResourceGroup { - key: string; - label: string; - folders: FolderItem[]; - files: FileItem[]; - } - - function folderGroupKey(folder: FolderItem): string { - if (groupBy === 'type') return t('files.file_types.folder', 'Folders'); - if (groupBy === 'size') return sizeBucket(-1); - if (groupBy === 'modifiedAt') return dateBucket(folder.modified_at); - if (groupBy === 'createdAt') return dateBucket(folder.created_at); - return ''; - } - function fileGroupKey(file: FileItem): string { - if (groupBy === 'type') return typeLabel(file.category); - if (groupBy === 'size') return sizeBucket(file.size ?? 0); - if (groupBy === 'modifiedAt') return dateBucket(file.modified_at); - if (groupBy === 'createdAt') return dateBucket(file.created_at); - return ''; - } - - // Grouped rendering: ordered lanes preserving the sorted folder-then-file order - // within each lane. Lanes appear in first-seen order (folders precede files). - const groups = $derived.by(() => { - if (groupBy === '') return []; - // Transient grouping map, local to this derivation and discarded once the - // array is built — must stay a plain Map (a reactive one created inside a - // $derived would be unsafe state). - // eslint-disable-next-line svelte/prefer-svelte-reactivity - const map = new Map(); - const ensure = (key: string): ResourceGroup => { - let g = map.get(key); - if (!g) { - g = { key, label: key, folders: [], files: [] }; - map.set(key, g); - } - return g; - }; - for (const folder of sortedFolders) ensure(folderGroupKey(folder)).folders.push(folder); - for (const file of sortedFiles) ensure(fileGroupKey(file)).files.push(file); - return [...map.values()]; - }); - - // Each group's folders + files folded into ONE ordered `Entry` stream - // (folders first, then files — the exact render order the un-windowed - // `{#each folders}{#each files}` produced), so each swimlane can feed a - // windowed instead of mounting every row/card - // (benches/ROUND13.md §V1). `buildFileRows` is intentionally identity- - // and order-only: it must NOT read favoriteIds/sharedIds/selection, or a - // single star/select toggle would rebuild every swimlane (the ROUND11 - // §S2 fine-grained-star invariant). - const groupedEntries = $derived( - groups.map((g) => ({ - key: g.key, - label: g.label, - entries: [ - ...g.folders.map((folder) => ({ kind: 'folder' as const, folder })), - ...g.files.map((file) => ({ kind: 'file' as const, file })) - ] as Entry[] - })) - ); - - // ── Toolbar controls (upload split-button + group-by popup menu) ───────── - // The group-by popup + sort-direction + view toggle live in the shared - // ; this page only owns the upload split-button dropdown. - let uploadMenuOpen = $state(false); - - interface GroupByDef { - key: GroupBy; - label: string; - icon: string; - /** Sort field implied by this dimension (the old group-by drove order_by). */ - sort?: SortField; - } - // Mirrors the old GROUP_BY_DEFS: "Name" is the default (flat, sorted by name) - // entry — there is no "None" option — followed by the swimlane dimensions. - const GROUP_BYS = $derived([ - { key: '', label: t('files.name', 'Name'), icon: 'arrow-up-a-z', sort: 'name' }, - { key: 'type', label: t('groupby.type', 'Type'), icon: 'layer-group', sort: 'type' }, - { key: 'size', label: t('groupby.size', 'Size'), icon: 'layer-group', sort: 'size' }, + // Same swimlane keys as the bespoke groups above (Type / Size / + // modifiedAt / createdAt). The `orderBy` values are what the + // GROUP_BYS toolbar emits, so 's onreload gets the + // legacy `sortField` name and can drive the same sort path. + const rlGroupBys = $derived([ + { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, + { + key: 'type', + label: t('groupby.type', 'Type'), + orderBy: 'type', + icon: 'layer-group', + bucketOf: (item) => + isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'), + labelOf: (k) => k + }, + { + key: 'size', + label: t('groupby.size', 'Size'), + orderBy: 'size', + icon: 'layer-group', + bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)), + labelOf: (k) => k + }, { key: 'modifiedAt', label: t('groupby.modifiedAt', 'Modified date'), + orderBy: 'modified_at', icon: 'layer-group', - sort: 'modified_at' + bucketOf: (item) => dateBucket(item.modified_at), + labelOf: (k) => k }, { key: 'createdAt', label: t('groupby.createdAt', 'Created date'), + orderBy: 'created_at', icon: 'layer-group', - sort: 'created_at' + bucketOf: (item) => dateBucket(item.created_at), + labelOf: (k) => k } ]); - /** Group-by chosen in the toolbar — also sets the matching sort field. */ - function onPickGroup(key: string) { - groupBy = key as GroupBy; - const def = GROUP_BYS.find((g) => g.key === key); - if (def?.sort) sortField = def.sort; + // ResourceList's `reversed` is a boolean; the legacy sort uses `1 | -1`. + // Two-way binding: setting `rlReversed` writes back into `sortDir`, and + // any programmatic sort direction change (e.g. group-by picking) mirrors + // out. + let rlReversed = $state(false); + $effect(() => { + rlReversed = sortDir === -1; + }); + $effect(() => { + sortDir = rlReversed ? -1 : 1; + }); + + // Bridge for 's callbacks — the row's open/favorite/drag + // props take one item; the legacy handlers take `(kind, id, name)`. + function rlOnOpen(item: FileItem | FolderItem) { + if (isFile(item)) openFile(item); + else openFolder(item); } + function rlOnFavorite(item: FileItem | FolderItem) { + void toggleFavorite(isFile(item) ? 'file' : 'folder', item.id); + } + function rlOnContextMenu(e: MouseEvent, item: FileItem | FolderItem) { + openContext(e, isFile(item) ? 'file' : 'folder', item.id, item.name); + } + // Row-drag: everything is draggable; only folders accept drops. + function rlIsDraggable(_item: FileItem | FolderItem): boolean { + return true; + } + function rlIsDropTarget(item: FileItem | FolderItem): boolean { + return !isFile(item); + } + function rlOnItemDragStart(e: DragEvent, item: FileItem | FolderItem) { + onItemDragStart(e, isFile(item) ? 'file' : 'folder', item.id, item.name); + } + function rlOnItemDragOver(e: DragEvent, item: FileItem | FolderItem) { + if (isFile(item)) return; + if (e.dataTransfer?.types.includes(DRAG_TYPE)) { + e.preventDefault(); + dropFolderId = item.id; + } + } + function rlOnItemDragLeave(_e: DragEvent, item: FileItem | FolderItem) { + if (dropFolderId === item.id) dropFolderId = null; + } + function rlOnItemDrop(e: DragEvent, item: FileItem | FolderItem) { + if (isFile(item)) return; + onFolderDrop(e, item); + } + + // ── Upload split-button popup state ───────────────────────────────────── + let uploadMenuOpen = $state(false); // Close the upload popup when clicking outside of it. $effect(() => { @@ -1660,19 +1580,7 @@ -
{ - e.preventDefault(); - dragOver = true; - }} - ondragleave={() => (dragOver = false)} - ondrop={onDrop} -> +
- - - (sortDir = (sortDir * -1) as 1 | -1)} - showDotfileToggle - > - {#snippet start()} - {#if selectedCount > 0} -
-
- - {t('files.selected_count', { count: selectedCount }, '{{count}} selected')} -
-
-
- - - - - -
-
-
- {:else} -
-
- - {#if uploadMenuOpen} -
- - -
- {/if} -
- + {#if uploadMenuOpen} +
+ +
{/if} - {/snippet} - - - -
- - {#if error} - - {:else if showSkeleton && isEmpty} - - {:else if isEmpty} - {#if hiddenCount > 0} - - - {:else} - - {/if} - {:else} -
- {#if groupBy !== '' && filesStore.viewMode === 'list'} - -
- {@render fileListHeader()} - {#each groupedEntries as group (group.key)} -
{group.label}
- - {/each} -
- {:else if groupBy !== ''} - -
- {#each groupedEntries as group (group.key)} -
- {group.label} -
- - {/each} -
- {:else if filesStore.viewMode === 'list'} - -
- {@render fileListHeader()} - -
- {:else} - - - {/if} -
- {/if} -
- -{#snippet fileListHeader()} -
-
- 0 && selectedCount === totalCount} - indeterminate={selectedCount > 0 && selectedCount < totalCount} - onchange={toggleSelectAll} - /> -
- {#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)} - {#if col.f === 'owner'} -
{col.l}
- {:else} - - {/if} - {/each} -
-
-{/snippet} - -{#snippet entryRow(e: Entry)} - {#if e.kind === 'folder'} - {@render folderRow(e.folder)} - {:else} - {@render fileRow(e.file)} - {/if} -{/snippet} - -{#snippet folderRow(folder: FolderItem)} -
onItemDragStart(e, 'folder', folder.id, folder.name)} - ondragover={(e) => { - if (e.dataTransfer?.types.includes(DRAG_TYPE)) { - e.preventDefault(); - dropFolderId = folder.id; - } - }} - ondragleave={() => { - if (dropFolderId === folder.id) dropFolderId = null; - }} - ondrop={(e) => onFolderDrop(e, folder)} - ondblclick={() => openFolder(folder)} - onclick={(e) => { - if (!handleSelectionClick(e, folder.id)) openFolder(folder); - }} - oncontextmenu={(e) => openContext(e, 'folder', folder.id, folder.name)} - onkeydown={(e) => e.key === 'Enter' && openFolder(folder)} - > -
- { - e.stopPropagation(); - toggleSelected(folder.id); - }} - /> -
-
-
- {folder.name} - {#if favoriteIds.has(folder.id)}
- -
{/if} - {#if sharedIds.has(folder.id)}
- -
{/if} -
-
- {relativeTimeAgo(folder.modified_at)} -
-
- {ownerLabel(folder.created_by, session.user?.id ?? null)} -
-
{t('files.file_types.folder', 'Folder')}
-
—
-
{formatDate(folder.modified_at)}
-
- - - - - - -
-
-{/snippet} - -{#snippet fileRow(file: FileItem)} - {@const iconName = iconNameFromClass(file.icon_class)} -
onItemDragStart(e, 'file', file.id, file.name)} - ondblclick={() => openFile(file)} - onclick={(e) => { - if (!handleSelectionClick(e, file.id)) openFile(file); - }} - oncontextmenu={(e) => openContext(e, 'file', file.id, file.name)} - onkeydown={(e) => e.key === 'Enter' && openFile(file)} - > -
- { - e.stopPropagation(); - toggleSelected(file.id); - }} - /> -
-
-
- - - {#if canThumbnail(file)} - { - // Server-side thumbnail is missing (404) — try client-side - // generation for image / PDF / video and PUT the result - // back so the next viewer gets the server thumbnail. - // Ported from the legacy static/js/features/thumbnail.js. - const img = e.currentTarget as HTMLImageElement; - img.style.display = 'none'; - if (!canThumbnailClientSide(file)) return; - if (file.mime_type === 'application/pdf') preloadPdf(); - void queueThumbnailGenerate(file, (dataUrl) => { - img.src = dataUrl; - img.style.display = ''; - }); - }} - /> - {/if}
- {file.name} - {#if favoriteIds.has(file.id)}
- -
{/if} - {#if sharedIds.has(file.id)}
- -
{/if} -
-
- {relativeTimeAgo(file.modified_at)} - {#if file.size != null}{formatBytes(file.size)}{/if} -
-
- {ownerLabel(file.created_by, session.user?.id ?? null)} -
-
{typeLabel(file.category)}
-
{file.size != null ? formatBytes(file.size) : ''}
-
{formatDate(file.modified_at)}
-
+ + {t('actions.new_folder', 'New folder')} + + {/snippet} + + {#snippet batchActions(sel)} void batchFavorites()} > + + {t('files.add_favorites', 'Add to favorites')} + - + {t('files.move', 'Move')} + + + - - -
-
-{/snippet} + + {t('common.delete', 'Delete')} + + {/snippet} + + {#snippet rowBadge(item)} + {#if favoriteIds.has(item.id)} + + + + {/if} + {#if sharedIds.has(item.id)} + + + + {/if} + {/snippet} + +
{#if moveDialog.component} {@const MoveDialog = moveDialog.component} @@ -2492,41 +2013,6 @@ min-height: 100%; } - .files-page.dropzone-active { - outline: 2px dashed var(--color-accent); - outline-offset: -8px; - border-radius: var(--radius-xl); - } - - .page-sticky-header { - display: flex; - flex-direction: column; - gap: var(--space-3); - } - - .action-cell { - display: flex; - gap: var(--space-1); - justify-content: flex-end; - } - - .btn-action--delete:hover { - color: var(--color-danger-text); - } - - .btn-action { - text-decoration: none; - } - - /* Owner column header — non-sortable, so it's a plain div rather than a - sort button. Inherits the header row's weight/colour. */ - .list-header-owner { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .item-badge { display: inline-flex; align-items: center; From 65ac8f76d2fb9885eb5723a1858f191df3884c3c Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 00:38:34 +0200 Subject: [PATCH 03/21] refactor(ui): ctrl + A or command + A to select all items --- .../src/lib/components/ResourceList.svelte | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 496bf8a2..62007e6a 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -540,6 +540,25 @@ onselectionchange?.(selected); } } + + // Ctrl+A (Linux / Windows) / ⌘+A (macOS) selects every visible row. + // Handled here rather than in each page's own `svelte:window` so all + // consumers get the shortcut for free — /files, /trash, /favorites, + // /recent, /shared-with-me — with identical semantics. Only fires + // when `selectable` is on, and only when the focused element isn't + // a text input (typing inside a search box shouldn't hijack it). + function onSelectAllShortcut(e: KeyboardEvent) { + if (!selectable) return; + if (!(e.ctrlKey || e.metaKey)) return; + if (e.key.toLowerCase() !== 'a') return; + const tag = (e.target as HTMLElement | null)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + // Also skip when the focus is inside a contentEditable region + // (rich-text popups, name inline-edit if ever added). + if ((e.target as HTMLElement | null)?.isContentEditable) return; + e.preventDefault(); + toggleSelectAll(); + } // `selectedItems` and the reap-stale effect below stay on the RAW // items — selection persists across a display-filter toggle, and // stale-selection cleanup only fires when items truly leave the @@ -779,9 +798,16 @@ }} /> {/if} + + {#if rowBadge} + {@render rowBadge(item, ctx)} + {/if} {item.name} - {#if rowBadge}{@render rowBadge(item, ctx)}{/if}
{#if showOwner}
@@ -803,8 +829,14 @@
{/if}
- {#if showDate && dateCell}{@render dateCell(item, ctx)}{/if} + {#if sizeVal != null}{formatBytes(sizeVal)}{/if} {#if dateVal != null}{formatDate(dateVal)}{/if} @@ -861,6 +893,11 @@
{/snippet} + + + + +{/if}
{#snippet listHeader()} @@ -1190,6 +1322,25 @@ pointer-events: none; } + /* ── Rubberband (marquee) selection ──────────────────────────── + Absolute overlay drawn while the user drags. Positioned inside + `.rl-root`; the pointer events go to the window listener, so + the rectangle itself is inert. `.rl-root--rubberbanding` + suppresses text selection under the cursor so dragging over row + text doesn't leave a highlighted mess behind. */ + .rl-root--rubberbanding { + user-select: none; + } + + .rl-rubberband { + position: absolute; + z-index: 5; + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + pointer-events: none; + } + /* ── Breadcrumb strip inside the sticky header ── */ .rl-breadcrumb { display: flex; From e3c3f6fe24db719ce545fecfe41d8620a6c62c49 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 00:55:48 +0200 Subject: [PATCH 06/21] refactor(ui): trash: show action buttons in grid view --- .../src/lib/styles/ported/resourceList.css | 93 ++++++++++--------- frontend/src/routes/trash/+page.svelte | 10 ++ 2 files changed, 57 insertions(+), 46 deletions(-) diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index e4d5b594..595ba4b8 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -708,13 +708,35 @@ outline-offset: 2px; } -/* More actions button (three dots) — top-right of the thumbnail on a scrim. */ -/* Grid cards surface actions through the corner kebab (.file-actions) + the - favorite star, both absolutely positioned below. The inline per-row action - buttons (share/move/rename/delete) belong to the list view only — hide them - here so they don't stack up along the bottom edge of the card. */ -.files-grid-view .file-item .action-cell .btn-action { - display: none; +/* ── Grid card action cluster ───────────────────────────────────── + Every action a row can surface — favorite star, `.file-actions` + kebab, per-section `.btn-action` icons (e.g. trash's Restore / + Delete permanently) — lives in a single `.action-cell` container + pinned to the top-right of the card. The container carries the + position + hover-reveal + gap; its children just supply their + own chip visuals (30x30 scrim pill, etc.), no more one-off + absolute positioning per child. + + Old rules put `.file-actions` and `.favorite-star` at hand-crafted + absolute coordinates and hid `.btn-action` entirely — that made + trash's per-item buttons invisible in grid view. The unified + container reads as one design pattern and takes whatever children + the row template hands it. */ +.files-grid-view .file-item .action-cell { + position: absolute; + top: calc(var(--space-3) + 8px); + right: calc(var(--space-3) + 8px); + z-index: 10; + display: flex; + gap: var(--space-1); + opacity: 0; + transition: opacity var(--motion-fast) var(--ease-standard); +} + +.files-grid-view .file-item:hover .action-cell, +.files-grid-view .file-item:focus-within .action-cell, +.files-grid-view .file-item .action-cell:has(.favorite-star.active) { + opacity: 1; } /* The favorite state is already shown by the corner star button, so the inline @@ -723,34 +745,32 @@ display: none; } -.files-grid-view .file-item .file-actions { - position: absolute; - top: calc(var(--space-3) + 8px); - right: calc(var(--space-3) + 8px); +/* Chip visuals for anything inside the corner cluster — the kebab, the star, + any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */ +.files-grid-view .file-item .action-cell .file-actions, +.files-grid-view .file-item .action-cell .favorite-star, +.files-grid-view .file-item .action-cell .btn-action { + position: static; width: 30px; height: 30px; - border-radius: var(--radius-full); + padding: 0; border: none; + border-radius: var(--radius-full); background: var(--color-scrim-control); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); box-shadow: 0 1px 3px var(--color-shadow-sm); - display: flex; + display: inline-flex; align-items: center; justify-content: center; - opacity: 0; - z-index: 10; - cursor: pointer; color: var(--color-text); font-size: var(--text-md); - transition: opacity var(--motion-fast) var(--ease-standard); -} - -.files-grid-view .file-item:hover .file-actions { + cursor: pointer; + /* Opacity/hover-reveal moves up to `.action-cell` — children stay opaque. */ opacity: 1; } -.files-grid-view .file-item .file-actions:hover { +.files-grid-view .file-item .action-cell .file-actions:hover { color: var(--color-accent); } @@ -782,34 +802,16 @@ line-height: var(--leading-none); } -/* Favorite star — top-right of the thumbnail, left of the kebab, on a scrim. */ +/* Favorite star — visual overrides only. Position, hover-reveal, chip + geometry all come from the shared corner-cluster rule on + `.files-grid-view .file-item .action-cell`. What's left here is just + the star's per-state colour: subtle at rest, active-gold when the + item is a favorite. `.active` still bumps the parent cluster's + opacity so an unhovered card can still show its star. */ .files-grid-view .file-item button.favorite-star { - position: absolute; - top: calc(var(--space-3) + 8px); - right: calc(var(--space-3) + 8px + 34px); - width: 30px; - height: 30px; - border-radius: var(--radius-full); - border: none; - background: var(--color-scrim-control); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - box-shadow: 0 1px 3px var(--color-shadow-sm); - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - z-index: 12; - cursor: pointer; color: var(--color-text-subtle); font-size: 15px; - padding: 0; line-height: var(--leading-none); - transition: opacity var(--motion-fast) var(--ease-standard); -} - -.files-grid-view .file-item:hover button.favorite-star { - opacity: 1; } .files-grid-view .file-item button.favorite-star:hover { @@ -817,7 +819,6 @@ } .files-grid-view .file-item button.favorite-star.active { - opacity: 1; color: var(--color-star-text-hover); } diff --git a/frontend/src/routes/trash/+page.svelte b/frontend/src/routes/trash/+page.svelte index 6a1117da..83913fdb 100644 --- a/frontend/src/routes/trash/+page.svelte +++ b/frontend/src/routes/trash/+page.svelte @@ -400,4 +400,14 @@ color: var(--color-danger-text); font-weight: var(--weight-semibold); } + + /* Grid-corner action-cell layout + chip visuals now live in the + shared `ported/resourceList.css`; every section using ResourceList + picks them up. What stays here is only the trash-specific danger + red on the "Delete permanently" button — `--color-error-text` is + the right red-text token (the shared `.file-actions:hover` accent + colour still lands on the plain `.btn-action` restore button). */ + :global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) { + color: var(--color-error-text); + } From 63589e595e38387d12831c8e56b2b319b336aed4 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 01:10:04 +0200 Subject: [PATCH 07/21] refactor(ui): recent: add a quick button to remove item from recent --- frontend/src/lib/api/endpoints/recent.ts | 17 ++++ .../src/lib/styles/ported/resourceList.css | 10 ++ frontend/src/routes/recent/+page.svelte | 98 +++++++++++-------- frontend/static/locales/ar.json | 3 +- frontend/static/locales/de.json | 3 +- frontend/static/locales/en.json | 3 +- frontend/static/locales/es.json | 3 +- frontend/static/locales/fa.json | 3 +- frontend/static/locales/fr.json | 3 +- frontend/static/locales/hi.json | 3 +- frontend/static/locales/it.json | 3 +- frontend/static/locales/ja.json | 3 +- frontend/static/locales/ko.json | 3 +- frontend/static/locales/nl.json | 3 +- frontend/static/locales/pl.json | 3 +- frontend/static/locales/pt.json | 3 +- frontend/static/locales/ru.json | 3 +- frontend/static/locales/zh-TW.json | 3 +- frontend/static/locales/zh.json | 3 +- 19 files changed, 115 insertions(+), 58 deletions(-) diff --git a/frontend/src/lib/api/endpoints/recent.ts b/frontend/src/lib/api/endpoints/recent.ts index ce5caa34..77820cee 100644 --- a/frontend/src/lib/api/endpoints/recent.ts +++ b/frontend/src/lib/api/endpoints/recent.ts @@ -30,3 +30,20 @@ export async function clearRecent(): Promise { }); if (!res.ok) throw new Error(`clear recent failed: ${res.status}`); } + +/** + * Remove a single item from the caller's recent history — the "broom" + * per-row affordance in the recent view. Distinct from `clearRecent` + * (which wipes every entry). 404 means the item wasn't in recents to + * begin with — treated as a no-op success by the caller. + */ +export async function removeFromRecent(kind: ItemType, id: string): Promise { + const res = await apiFetch(`/api/recent/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: getCsrfHeaders() + }); + if (!res.ok && res.status !== 404) { + throw new Error(`remove from recent failed: ${res.status}`); + } +} diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 595ba4b8..47a6ecc2 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -753,6 +753,11 @@ position: static; width: 30px; height: 30px; + /* `margin: 0` overrides the legacy `.files-grid-view .file-item + .btn-action { margin-top: var(--space-1) }` rule further down — + inside the corner cluster the parent's `gap` handles spacing + and any per-child margin would misalign the pills. */ + margin: 0; padding: 0; border: none; border-radius: var(--radius-full); @@ -1188,6 +1193,11 @@ color: var(--color-text-dark); } +/* Legacy: a margin-top on `.btn-action` in grid view for the era when + these buttons flowed at the bottom of the card. Kept for any + free-standing use outside the corner cluster; reset inside + `.action-cell` (line ~745) so the broom / restore / delete pills + align with the kebab and star. */ .files-grid-view .file-item .btn-action { margin-top: var(--space-1); } diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 47190285..961e8f8b 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -5,14 +5,16 @@ import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; import { onMount } from 'svelte'; - import { SvelteMap, SvelteSet } from 'svelte/reactivity'; + import { SvelteMap } from 'svelte/reactivity'; import { primeContextPage } from '$lib/utils/listContext'; - import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent'; import { - addFavorite, + clearRecent, + fetchRecentPage, + removeFromRecent, + type RecentResourceItem + } from '$lib/api/endpoints/recent'; + import { dateBucket, - fetchFavoritesPage, - removeFavorite, resolveOwnerName, sizeBucket, typeLabel @@ -31,12 +33,10 @@ // `preferences.hideDotfiles` + `isDotfile` are read here only to // derive `hiddenCount` for the empty-state message — the actual // filter is inside ResourceList (gated on `showDotfileToggle`). - // `replaceSet` is from perf-round-6: `loadFavoriteIds` mutates - // the reactive SvelteSet in place instead of re-creating it. import { preferences } from '$lib/stores/preferences.svelte'; import { isDotfile } from '$lib/utils/dotfileFilter'; - import { replaceSet } from '$lib/utils/sets'; import { t } from '$lib/i18n/index.svelte'; + import Icon from '$lib/icons/Icon.svelte'; let raw = $state([]); let cursor = $state(undefined); @@ -45,9 +45,6 @@ let groupBy = $state(''); let reversed = $state(false); const owners = useOwnerCache(resolveOwnerName); - // In-place reactive set — a star toggle skips the full-set copy and - // spares the other favorited rows' readers. - const favoriteIds = new SvelteSet(); // Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId` // (Recent's provenance semantic — "who touched this recently" — differs @@ -62,7 +59,7 @@ const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem)); // Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2) // instead of rebuilding a fresh Map that re-hashes the whole accumulated list - // on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet. + // on every infinite-scroll page. const contextMap = new SvelteMap(); const hiddenCount = $derived( preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0 @@ -104,18 +101,6 @@ } ]; - async function loadFavoriteIds() { - try { - const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] }); - replaceSet( - favoriteIds, - favs.items.map((f) => f.resource.id) - ); - } catch { - // non-fatal — stars just default to off - } - } - // Recent defaults to most-recently-accessed first (accessed_at DESC). async function load(reset = false, orderBy = 'accessed_at', rev = reversed) { loading = true; @@ -173,24 +158,32 @@ viewerOpen = true; } - // Callback signature is `FileItem | FolderItem` (ResourceList - // hands raw items to `onfavorite` — the pre-migration - // `ResourceEntry` shape is gone). Set mutation is in-place per - // perf-round-6: 1 000 toggles @ N=5 000 dropped from 771.9 ms - // to 1.9 ms by skipping the full-set copy that every reader of - // `favoriteIds` used to see. - async function toggleFavorite(item: FileItem | FolderItem) { - const isFav = favoriteIds.has(item.id); + /** + * Remove a single item from the caller's recent history. The + * per-row "broom" affordance replaces the favorite-star that + * existed here before — /recent is a history view, so surfacing + * "forget this one" is more useful than "favorite this one" + * (users go to the item's real home to favorite it). + * + * Optimistic: the row disappears immediately; if the DELETE + * fails, we re-add it at its original position and toast the + * error so the state stays honest. + */ + async function removeItem(item: FileItem | FolderItem) { const kind = kindOf(item); - // Optimistic in-place toggle, reverted on failure. - if (isFav) favoriteIds.delete(item.id); - else favoriteIds.add(item.id); + const idx = raw.findIndex((it) => it.resource.id === item.id); + if (idx < 0) return; + const snapshot = raw[idx]; + raw = raw.filter((it) => it.resource.id !== item.id); + contextMap.delete(item.id); try { - if (isFav) await removeFavorite(kind, item.id); - else await addFavorite(kind, item.id); + await removeFromRecent(kind, item.id); } catch (e) { - if (isFav) favoriteIds.add(item.id); - else favoriteIds.delete(item.id); + raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)]; + contextMap.set(item.id, { + date: snapshot.accessed_at, + ownerId: snapshot.resource.updated_by ?? null + }); errorToast(e); } } @@ -325,7 +318,6 @@ } onMount(() => { - void loadFavoriteIds(); void load(true); }); @@ -336,7 +328,6 @@ title={t('nav.recent', 'Recent')} {items} {contextMap} - {favoriteIds} resolveOwnerName={(id) => owners.name(id)} {loading} {error} @@ -354,7 +345,6 @@ hasMore={!!cursor} onloadmore={() => load(false, orderByForGroup())} onopen={open} - onfavorite={toggleFavorite} showOwner showPath showDotfileToggle @@ -397,6 +387,30 @@ onclick={() => batchDelete(sel)}>{t('common.delete', 'Delete')} {/snippet} + {#snippet itemActions(item)} + + + {/snippet} {#if fileViewer.component} diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index 54dfef91..bec11111 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -583,7 +583,8 @@ "empty_hint": "الملفات التي تفتحها ستظهر هنا", "empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك", "empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.", - "loadMore": "تحميل المزيد" + "loadMore": "تحميل المزيد", + "remove_item": "إزالة من الأخيرة" }, "notifications": { "file_renamed": "تمت إعادة تسمية الملف", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 406372ad..426ff4d5 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -583,7 +583,8 @@ "empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt", "empty_hidden_state": "{{n}} zuletzt verwendete(s) Element(e) durch Ihre Einstellung ausgeblendet", "empty_hidden_hint": "Deaktivieren Sie \"Verborgene Dateien ausblenden\" in Ihrem Profil, um sie anzuzeigen.", - "loadMore": "Mehr laden" + "loadMore": "Mehr laden", + "remove_item": "Aus zuletzt verwendet entfernen" }, "notifications": { "file_renamed": "Datei umbenannt", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 74281e11..dace2430 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -754,7 +754,8 @@ "empty_hidden_state": "{{n}} recent item(s) hidden by your dotfile preference", "empty_hidden_hint": "Turn off \"Hide dotfiles\" in your profile to see them.", "loadMore": "Load more", - "confirm_clear": "Clear your recent items?" + "confirm_clear": "Clear your recent items?", + "remove_item": "Remove from recent" }, "notifications": { "file_renamed": "File renamed", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index a921a8d5..f44e243f 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -588,7 +588,8 @@ "empty_hint": "Los archivos que abras aparecerán aquí", "empty_hidden_state": "{{n}} elemento(s) reciente(s) oculto(s) por tu preferencia", "empty_hidden_hint": "Desactiva \"Ocultar archivos ocultos\" en tu perfil para verlos.", - "loadMore": "Cargar más" + "loadMore": "Cargar más", + "remove_item": "Quitar de recientes" }, "notifications": { "file_renamed": "Archivo renombrado", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index baabb94e..0137754c 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -583,7 +583,8 @@ "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند", "empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است", "empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.", - "loadMore": "بارگذاری بیشتر" + "loadMore": "بارگذاری بیشتر", + "remove_item": "حذف از اخیر" }, "batch": { "one_selected": "۱ مورد انتخاب شده", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index e79b9d3f..af817840 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -583,7 +583,8 @@ "empty_hint": "Les fichiers que vous ouvrez apparaîtront ici", "empty_hidden_state": "{{n}} élément(s) récent(s) masqué(s) par votre préférence", "empty_hidden_hint": "Désactivez \"Masquer les fichiers\" dans votre profil pour les voir.", - "loadMore": "Charger plus" + "loadMore": "Charger plus", + "remove_item": "Retirer des récents" }, "notifications": { "file_renamed": "Fichier renommé", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 83e871b3..de4fc33b 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -583,7 +583,8 @@ "empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी", "empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं", "empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।", - "loadMore": "और लोड करें" + "loadMore": "और लोड करें", + "remove_item": "हाल के से हटाएँ" }, "notifications": { "file_renamed": "फ़ाइल का नाम बदला गया", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index f0213557..8b0a6e4e 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -583,7 +583,8 @@ "empty_hint": "I file che apri appariranno qui", "empty_hidden_state": "{{n}} elemento/i recente/i nascosto/i dalla tua preferenza", "empty_hidden_hint": "Disattiva \"Nascondi i file nascosti\" nel tuo profilo per vederli.", - "loadMore": "Carica altri" + "loadMore": "Carica altri", + "remove_item": "Rimuovi dai recenti" }, "notifications": { "file_renamed": "File rinominato", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index c24eb809..90afc95b 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -583,7 +583,8 @@ "empty_hint": "開いたファイルがここに表示されます", "empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります", "empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。", - "loadMore": "さらに読み込む" + "loadMore": "さらに読み込む", + "remove_item": "最近使用したものから削除" }, "notifications": { "file_renamed": "ファイル名を変更しました", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 366d5daa..f7247818 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -717,7 +717,8 @@ "empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개", "empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.", "loadMore": "더 불러오기", - "confirm_clear": "최근 항목을 지우시겠습니까?" + "confirm_clear": "최근 항목을 지우시겠습니까?", + "remove_item": "최근에서 제거" }, "notifications": { "file_renamed": "파일 이름이 변경되었습니다", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 0df6d4a3..9c85988c 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -583,7 +583,8 @@ "empty_hint": "Bestanden die je opent verschijnen hier", "empty_hidden_state": "{{n}} recent(e) item(s) verborgen door je voorkeur", "empty_hidden_hint": "Schakel \"Verborgen bestanden verbergen\" uit in je profiel om ze te zien.", - "loadMore": "Meer laden" + "loadMore": "Meer laden", + "remove_item": "Uit recent verwijderen" }, "notifications": { "file_renamed": "Bestand hernoemd", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index c3623cf2..4bb34dfa 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -583,7 +583,8 @@ "empty_hint": "Otwarte pliki pojawią się tutaj", "empty_hidden_state": "{{n}} ostatnich elementów ukrytych zgodnie z Twoją preferencją", "empty_hidden_hint": "Wyłącz \"Ukryj ukryte pliki\" w swoim profilu, aby je zobaczyć.", - "loadMore": "Załaduj więcej" + "loadMore": "Załaduj więcej", + "remove_item": "Usuń z ostatnich" }, "notifications": { "file_renamed": "Zmieniono nazwę pliku", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 49f2d839..2cf24b96 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -583,7 +583,8 @@ "empty_hint": "Os arquivos que você abrir aparecerão aqui", "empty_hidden_state": "{{n}} item(ns) recente(s) oculto(s) pela sua preferência", "empty_hidden_hint": "Desative \"Ocultar arquivos ocultos\" no seu perfil para vê-los.", - "loadMore": "Carregar mais" + "loadMore": "Carregar mais", + "remove_item": "Remover dos recentes" }, "notifications": { "file_renamed": "Arquivo renomeado", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 5dec0839..7a3c6fee 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -583,7 +583,8 @@ "empty_hint": "Открытые вами файлы будут отображаться здесь", "empty_hidden_state": "Недавних элементов скрыто: {{n}}", "empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.", - "loadMore": "Загрузить ещё" + "loadMore": "Загрузить ещё", + "remove_item": "Удалить из недавних" }, "notifications": { "file_renamed": "Файл переименован", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 135970b9..715eff41 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -583,7 +583,8 @@ "empty_hint": "您開啟的檔案將顯示在這裡", "empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目", "empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。", - "loadMore": "載入更多" + "loadMore": "載入更多", + "remove_item": "從最近項目中移除" }, "batch": { "one_selected": "已選擇 1 個專案", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 5ef8ca75..579f04cb 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -583,7 +583,8 @@ "empty_hint": "您打开的文件将显示在这里", "empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目", "empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。", - "loadMore": "加载更多" + "loadMore": "加载更多", + "remove_item": "从最近使用中移除" }, "batch": { "one_selected": "已选择 1 个项目", From 7ffb7bf0ae2be8a5c8d8a500a3cf439fd7a8565b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 01:21:48 +0200 Subject: [PATCH 08/21] feat(ui): add 'open parent directory' in recent and favorite section --- .../src/lib/components/ResourceList.svelte | 15 ++- frontend/src/lib/utils/folderAccess.ts | 96 +++++++++++++++++++ frontend/src/routes/favorites/+page.svelte | 35 +++++++ frontend/src/routes/recent/+page.svelte | 37 +++++++ 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/utils/folderAccess.ts diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index cea00a9a..1b38da25 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -50,6 +50,16 @@ label: string; icon: string; danger?: boolean; + /** + * Optional per-item visibility gate. Called at menu-open time + * with the target item + context; return `false` to hide the + * entry for that row. Synchronous by contract — pages that need + * an async check (e.g. "does the caller have Read on the parent + * folder?") should pre-warm a cache when items load so the + * answer is already resolved by the time this runs. See + * `$lib/utils/folderAccess.ts` for the reference pattern. + */ + visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean; run: (item: FileItem | FolderItem, ctx?: ItemContext) => void; } @@ -1261,6 +1271,9 @@ {/snippet} {#if ctxOpen && ctxItem && contextActions} + {@const visibleActions = contextActions.filter( + (a) => a.visible?.(ctxItem!, ctxOf(ctxItem!.id)) !== false + )} - {/snippet} - {#snippet end()} - - {/snippet} - -
+
0 && batchActions} + > + {#if selectable && selected.size > 0 && batchActions} + + {t('files.selected_count', { count: selected.size }, '{{count}} selected')} +
+ {@render batchActions(selectedItems)} +
+ {:else if actions} + {@render actions()} + {/if} +
+ {/snippet} + {#snippet end()} + + {/snippet} +
+ -{#if error} - -{:else if loading && isEmpty} - -{:else if isEmpty} - -{:else} -
- {#if grouped && filesStore.viewMode === 'list'} -
- {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} - {#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - - {/if} -
- - e.id} {row} /> - {/each} -
- {:else if grouped} - -
- {#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - - {/if} -
- e.id} - {row} - /> - {/each} -
- {:else if filesStore.viewMode === 'list'} - -
- {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} - e.id} {row} /> -
- {:else} - - e.id} - {row} - /> - {/if} +
+ {#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} + e.id} {row} /> +
+ {:else} + + e.id} + {row} + /> + {/if} - {#if hasMore} - - {/if} - - -
-{/if} -{#if rubberband} - + + + {/if} + {#if rubberband} + - -{/if} - + + {/if} + + {#snippet listHeader()}
diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index dd1708ca..5d040664 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -1,6 +1,4 @@ @@ -352,25 +331,25 @@ }} > {#snippet batchActions(sel)} + - sel.forEach(unfavorite)} + >{t('files.unfavorite', 'Remove favorite')} {/snippet} diff --git a/frontend/src/routes/favorites/page.test.ts b/frontend/src/routes/favorites/page.test.ts index cd0277ff..989a45bc 100644 --- a/frontend/src/routes/favorites/page.test.ts +++ b/frontend/src/routes/favorites/page.test.ts @@ -86,13 +86,18 @@ it('unfavorites a row via the star button', async () => { await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1')); }); -it('batch-deletes selected favorites after confirmation', async () => { +it('batch-removes-from-favorite the selection', async () => { + // /favorites' batch bar was intentionally trimmed to Download + + // Remove-from-favorite. Bulk-deleting the underlying file from + // this view (previous behaviour) confused the "this is a + // bookmarks list" semantics — destructive actions belong in the + // row's context menu, not in the batch bar. This test pins the + // new shape: batch button just un-stars the selection. withOneFile(); - confirmDialog.mockResolvedValue(true); - m(deleteFile).mockResolvedValue(undefined); + m(removeFavorite).mockResolvedValue(undefined); render(FavoritesPage); await screen.findByText('photo.png'); await fireEvent.click(screen.getByTestId('resource-list-select-f1-checkbox')); - await fireEvent.click(await screen.findByTestId('favorites-batch-delete-btn')); - await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1')); + await fireEvent.click(await screen.findByTestId('favorites-batch-remove-btn')); + await waitFor(() => expect(removeFavorite).toHaveBeenCalledWith('file', 'f1')); }); diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 84c7e0b5..c1ff3477 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -319,31 +319,10 @@ // prunes its own selection when items reload) — benches/ROUND11.md §S1. type Selectable = FileItem | FolderItem; - function batchTargets(sel: Selectable[]) { - return sel.map((i) => ({ id: i.id, name: i.name, kind: kindOf(i) })); - } - function batchDownload(sel: Selectable[]) { for (const i of sel) downloadItem(i); } - async function batchDelete(sel: Selectable[]) { - const ok = await confirmDialog({ - title: t('common.delete', 'Delete'), - message: t('files.confirm_delete_n', { count: sel.length }, 'Delete {{count}} item(s)?'), - confirmText: t('common.delete', 'Delete'), - danger: true - }); - if (!ok) return; - try { - await Promise.all(sel.map((i) => (isFile(i) ? deleteFile(i.id) : deleteFolder(i.id)))); - const removed = new Set(sel.map((i) => i.id)); - raw = raw.filter((i) => !removed.has(i.resource.id)); - } catch (e) { - errorToast(e); - } - } - onMount(() => { void load(true); }); @@ -401,25 +380,26 @@ {/if} {/snippet} {#snippet batchActions(sel)} + - sel.forEach(removeItem)} + >{t('recent.remove_item', 'Remove from recent')} {/snippet} {#snippet itemActions(item)} diff --git a/frontend/src/routes/recent/page.test.ts b/frontend/src/routes/recent/page.test.ts index 63076714..1aae3136 100644 --- a/frontend/src/routes/recent/page.test.ts +++ b/frontend/src/routes/recent/page.test.ts @@ -92,15 +92,20 @@ it('removes a recent row via the broom button', async () => { await waitFor(() => expect(removeFromRecent).toHaveBeenCalledWith('file', 'r1')); }); -it('batch-deletes selected recent items after confirmation', async () => { +it('batch-removes-from-recent the selection', async () => { + // /recent's batch bar was intentionally trimmed to Download + + // Remove-from-recent. Bulk-deleting the underlying file from + // this history view (previous behaviour) confused the "this is + // activity log" semantics — destructive actions belong in the + // row's context menu, not in the batch bar. This test pins the + // new shape: batch button just forgets the selection from history. withOneFile(); - confirmDialog.mockResolvedValue(true); - m(deleteFile).mockResolvedValue(undefined); + m(removeFromRecent).mockResolvedValue(undefined); render(RecentPage); await screen.findByText('notes.txt'); await fireEvent.click(screen.getByTestId('resource-list-select-r1-checkbox')); - await fireEvent.click(await screen.findByTestId('recent-batch-delete-btn')); - await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('r1')); + await fireEvent.click(await screen.findByTestId('recent-batch-remove-btn')); + await waitFor(() => expect(removeFromRecent).toHaveBeenCalledWith('file', 'r1')); }); it('renders an empty state when there is no recent activity', async () => { diff --git a/frontend/src/routes/trash/+page.svelte b/frontend/src/routes/trash/+page.svelte index 83913fdb..beb4c2af 100644 --- a/frontend/src/routes/trash/+page.svelte +++ b/frontend/src/routes/trash/+page.svelte @@ -16,6 +16,7 @@ import { formatDate } from '$lib/utils/display'; import type { Drive, FileItem, FolderItem, TrashResourceItem } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; + import Button from '$lib/components/Button.svelte'; import ResourceList, { isFile, type GroupByDef, @@ -290,24 +291,27 @@ {/if} {/snippet} {#snippet batchActions(sel)} - - - {t('trash.restore', 'Restore')} - - - - {t('trash.delete', 'Delete permanently')} - {/snippet} {#snippet rowBadge(_item, ctx)} {@const chip = expiryChip(ctx?.date)} diff --git a/tests/e2e/spa/favorites.spec.ts b/tests/e2e/spa/favorites.spec.ts index 9d5a476a..165fd0f1 100644 --- a/tests/e2e/spa/favorites.spec.ts +++ b/tests/e2e/spa/favorites.spec.ts @@ -55,8 +55,12 @@ test('favorites batch select-all then move dialog', async ({ page }) => { await page.getByTestId('resource-list-select-all-checkbox').check(); await expect(page.getByTestId('resource-list-batch-close-btn')).toBeVisible(); - // Batch-move opens the move dialog; cancel it. - await page.getByTestId('favorites-batch-move-btn').click(); - await expect(page.getByTestId('move-dialog')).toBeVisible({ timeout: 15_000 }); - await page.getByTestId('move-dialog-cancel-btn').click(); + // Batch-remove-from-favorite un-stars every selected row without + // touching the underlying file — the /favorites batch bar was + // trimmed to Download + Remove-from-favorite (destructive-to-content + // actions moved into the row context menu). Verify the two folders + // vanish from the list after the click. + await page.getByTestId('favorites-batch-remove-btn').click(); + await expect(page.getByTestId(f1)).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId(f2)).toHaveCount(0); }); diff --git a/tests/e2e/spa/recent.spec.ts b/tests/e2e/spa/recent.spec.ts index ddd24cd5..ad9b0c0a 100644 --- a/tests/e2e/spa/recent.spec.ts +++ b/tests/e2e/spa/recent.spec.ts @@ -23,12 +23,15 @@ test('recent shows accessed items, batch selection, and clear', async ({ page }) await expect(page.getByTestId('appshell-logo-link')).toBeVisible({ timeout: 15_000 }); // Switch to list view (reveals the select-all header) and batch-select. + // /recent's batch bar was trimmed to Download + Remove-from-recent + // (destructive-to-content actions moved into the row context menu), + // so this exercises the new remove-from-recent batch instead of the + // old batch-move-into-dialog flow. await page.getByTestId('display-mode-view-list-btn').click({ timeout: 3_000 }).catch(() => {}); const selectAll = page.getByTestId('resource-list-select-all-checkbox'); if (await selectAll.isVisible().catch(() => false)) { await selectAll.check(); - await page.getByTestId('recent-batch-move-btn').click({ timeout: 3_000 }).catch(() => {}); - await page.getByTestId('move-dialog-cancel-btn').click({ timeout: 3_000 }).catch(() => {}); + await page.getByTestId('recent-batch-remove-btn').click({ timeout: 3_000 }).catch(() => {}); } // Clear the history if the control is present. From 4873a5e83752d845926cfdf39ac627782b0eeefb Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 19:32:03 +0200 Subject: [PATCH 13/21] feat(ui:items): normalize context menu --- .../src/lib/components/ResourceList.svelte | 40 +++++++++-- frontend/src/routes/favorites/+page.svelte | 30 +++++++-- .../src/routes/files/[...path]/+page.svelte | 2 +- frontend/src/routes/recent/+page.svelte | 32 +++++++-- .../src/routes/shared-with-me/+page.svelte | 66 ++++++++++++++++++- 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index df705c7e..c190eb0b 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -53,13 +53,22 @@ /** * Optional per-item visibility gate. Called at menu-open time * with the target item + context; return `false` to hide the - * entry for that row. Synchronous by contract — pages that need - * an async check (e.g. "does the caller have Read on the parent - * folder?") should pre-warm a cache when items load so the - * answer is already resolved by the time this runs. See - * `$lib/utils/folderAccess.ts` for the reference pattern. + * entry entirely for that row (e.g. `open_parent` on a drive- + * root folder that has no parent to open). Prefer `disabled?` + * over hiding when the action *could* apply but the caller + * lacks the required permission — a greyed entry answers + * "this option exists" for the user instead of leaving a hole + * that reads as a forgotten feature. */ visible?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean; + /** + * Optional per-item disabled gate. Called at menu-open time; + * `true` renders the entry non-interactive (dimmed, no click). + * Kept sync by the same contract as `visible?` — use the + * `menuPrepare` prop to prime any cache the predicate depends + * on before the menu renders. + */ + disabled?: (item: FileItem | FolderItem, ctx?: ItemContext) => boolean; run: (item: FileItem | FolderItem, ctx?: ItemContext) => void; } @@ -1352,12 +1361,17 @@ data-testid="resource-list-context-menu" > {#each visibleActions as action (action.key)} + {@const dis = action.disabled?.(ctxItem!, ctxOf(ctxItem!.id)) === true} {t('files.share', 'Share')} sel.forEach(unfavorite)}>{t('files.unfavorite', 'Remove favorite')} {/snippet} diff --git a/frontend/src/routes/favorites/page.test.ts b/frontend/src/routes/favorites/page.test.ts index 989a45bc..54fd3ea2 100644 --- a/frontend/src/routes/favorites/page.test.ts +++ b/frontend/src/routes/favorites/page.test.ts @@ -26,7 +26,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog })); import { fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites'; -import { deleteFile } from '$lib/api/endpoints/files'; import FavoritesPage from './+page.svelte'; const m = (fn: unknown) => fn as ReturnType; diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 6a634709..d500a134 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -48,9 +48,12 @@ let reversed = $state(false); const owners = useOwnerCache(resolveOwnerName); - // Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId` - // (Recent's provenance semantic — "who touched this recently" — differs - // from Favorites'/Files' `created_by`). + // Envelope shape: `accessed_at` → `ctx.date`, `created_by` → `ctx.ownerId`. + // Recent is a per-user view of items the caller accessed; the "who + // touched this last" (`updated_by`) semantic is real but adds noise + // (mostly the current user), so we align with Files / Favorites and + // show the original author instead. Cross-surface consistency wins + // over the finer-grained signal. // // Dotfile hiding is delegated to ResourceList via `showDotfileToggle` // — the component reads `preferences.hideDotfiles` and drops matching @@ -117,10 +120,10 @@ raw = reset ? page.items : [...raw, ...page.items]; primeContextPage(contextMap, reset, page.items, (it) => [ it.resource.id, - { date: it.accessed_at, ownerId: it.resource.updated_by ?? null } + { date: it.accessed_at, ownerId: it.resource.created_by ?? null } ]); cursor = page.next_cursor; - void owners.resolve(page.items.map((i) => i.resource.updated_by)); + void owners.resolve(page.items.map((i) => i.resource.created_by)); } catch (e) { console.error('recent: load error', e); error = t('errors_loadFailed', 'Failed to load items'); @@ -184,7 +187,7 @@ raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)]; contextMap.set(item.id, { date: snapshot.accessed_at, - ownerId: snapshot.resource.updated_by ?? null + ownerId: snapshot.resource.created_by ?? null }); errorToast(e); } diff --git a/frontend/src/routes/recent/page.test.ts b/frontend/src/routes/recent/page.test.ts index 1aae3136..a9f7f73c 100644 --- a/frontend/src/routes/recent/page.test.ts +++ b/frontend/src/routes/recent/page.test.ts @@ -25,7 +25,6 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn(), deleteFold vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog })); import { fetchRecentPage, clearRecent, removeFromRecent } from '$lib/api/endpoints/recent'; -import { deleteFile } from '$lib/api/endpoints/files'; import RecentPage from './+page.svelte'; const m = (fn: unknown) => fn as ReturnType; diff --git a/frontend/src/routes/trash/+page.svelte b/frontend/src/routes/trash/+page.svelte index beb4c2af..bc565334 100644 --- a/frontend/src/routes/trash/+page.svelte +++ b/frontend/src/routes/trash/+page.svelte @@ -300,17 +300,14 @@ standard action-bar sizing and reads consistently with `/recent` and `/favorites` batch clusters. --> - sel.forEach(restore)} + >{t('trash.restore', 'Restore')} sel.forEach(purge)}>{t('trash.delete', 'Delete permanently')} {/snippet} {#snippet rowBadge(_item, ctx)} @@ -414,4 +411,17 @@ :global(.files-grid-view .file-item .action-cell .btn-action--delete:hover) { color: var(--color-error-text); } + + /* List view: hide the expiry chip that ResourceList paints inside + `.file-icon__badge`. In list mode the same info is already in + the "Expires at" column (`dateCell` snippet above) — showing + the chip on the tiny row icon crops it and duplicates the + signal. Grid view keeps the chip: no dedicated column exists + there and the badge is the ONLY expiration surface on the + card. Scoped to trash because trash is the only section + emitting a rowBadge today; if another section starts using it, + this rule stays inert for them. */ + :global(.files-list-view .file-item .file-icon__badge) { + display: none; + } diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 01f9b18b..5a28b6ad 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -127,6 +127,11 @@ pub struct FavoriteResourceRow { /// folder rows. Routes into `FileDto::content_hash` and feeds /// `File::compute_etag` to populate `FileDto::etag`. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). + pub created_by: Option, + /// §14 provenance — who last touched the row. + pub updated_by: Option, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub favorited_at: DateTime, diff --git a/src/application/dtos/folder_dto.rs b/src/application/dtos/folder_dto.rs index a5207c0b..620fb78d 100644 --- a/src/application/dtos/folder_dto.rs +++ b/src/application/dtos/folder_dto.rs @@ -219,6 +219,13 @@ pub struct FolderResourceRow { /// on the REST `/api/folders/{id}/resources` listing so API /// consumers can issue conditional requests against listed files. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator was + /// deleted (FK `ON DELETE SET NULL`). Populates + /// `FileDto::created_by` / `FolderDto::created_by` on the listing so + /// the UI can render the owner column without a follow-up query. + pub created_by: Option, + /// §14 provenance — who last touched the row. + pub updated_by: Option, // Pre-computed sort fields — returned by the SQL for cursor construction. /// `LOWER(name)` used by `name`/`type` sorts. pub sort_str: String, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index cad3d101..635b4235 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -112,6 +112,16 @@ pub struct RecentResourceRow { /// folder rows. Feeds `File::compute_etag` so this listing's /// `etag` matches GET/HEAD/PROPFIND for the same file. pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). Powers the owner column + /// on the `/recent` UI (aligned with `/files` and `/favorites` + /// for cross-surface consistency, rather than the finer-grained + /// but noisier "who touched this last" signal). + pub created_by: Option, + /// §14 provenance — who last touched the row. Not currently + /// consumed by the UI but surfaced for API parity with the other + /// listing endpoints. + pub updated_by: Option, /// `true` when `owner_id == requesting user_id`. pub is_owner: bool, pub accessed_at: DateTime, diff --git a/src/application/dtos/trash_dto.rs b/src/application/dtos/trash_dto.rs index e431f5c6..9c9c5a1f 100644 --- a/src/application/dtos/trash_dto.rs +++ b/src/application/dtos/trash_dto.rs @@ -71,6 +71,12 @@ pub struct TrashResourceRow { /// same file (restorable trash items are conditional-request /// targets too). pub blob_hash: Option, + /// §14 provenance — who created the row. `None` when the creator + /// was deleted (FK `ON DELETE SET NULL`). + pub created_by: Option, + /// §14 provenance — who last touched the row (includes the trash + /// action itself, which stamps `updated_by = caller_id`). + pub updated_by: Option, pub trashed_at: DateTime, pub deletion_date: DateTime, /// Original location path (for folders: `path`; for files: `parent.path || '/' || name`). diff --git a/src/application/services/trash_service.rs b/src/application/services/trash_service.rs index 6fa56bae..728a1fb1 100644 --- a/src/application/services/trash_service.rs +++ b/src/application/services/trash_service.rs @@ -888,9 +888,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the trash listing query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; TrashResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -932,9 +931,8 @@ fn row_to_item_dto(row: TrashResourceRow) -> TrashResourceItemDto { sort_date: None, content_hash, etag, - // §14 provenance not selected by the trash listing query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; TrashResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 392f7d60..0e735d82 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -318,6 +318,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { fld.drive_id AS drive_id, NULL::text AS blob_hash, fld.created_by AS created_by, + fld.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -350,6 +351,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { f.drive_id AS drive_id, f.blob_hash, f.created_by AS created_by, + f.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -529,7 +531,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.is_owner, r.favorited_at, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.is_owner, r.favorited_at, r.resource_path, r.sort_str, r.type_order, r.folder_first{username_col} FROM resources r {user_join} @@ -602,6 +605,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), favorited_at: row.get("favorited_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index d59ae393..03d4ab4b 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1446,6 +1446,8 @@ impl FolderDbRepository { f.updated_at AS modified_at, f.drive_id, NULL::text AS blob_hash, + f.created_by, + f.updated_by, LOWER(f.name) AS sort_str, 0::bigint AS type_order, 0::int AS folder_first @@ -1465,6 +1467,8 @@ impl FolderDbRepository { fm.updated_at AS modified_at, fm.drive_id, fm.blob_hash, + fm.created_by, + fm.updated_by, LOWER(fm.name) AS sort_str, fm.category_order::bigint AS type_order, 1::int AS folder_first @@ -1655,6 +1659,7 @@ impl FolderDbRepository { let sql = format!( "SELECT resource_type, id, name, folder_id, mime_type, size, \ created_at, modified_at, drive_id, blob_hash, \ + created_by, updated_by, \ sort_str, type_order, folder_first \ FROM ({inner}) r \ {outer_order} \ @@ -1663,6 +1668,7 @@ impl FolderDbRepository { // Row: (resource_type, id, name, folder_id, mime_type, size, // created_at, modified_at, drive_id, blob_hash, + // created_by, updated_by, // sort_str, type_order, folder_first) type Row = ( String, @@ -1675,6 +1681,8 @@ impl FolderDbRepository { chrono::DateTime, Uuid, // drive_id Option, + Option, // created_by + Option, // updated_by String, i64, i32, @@ -1706,9 +1714,11 @@ impl FolderDbRepository { modified_at: r.7, drive_id: r.8, blob_hash: r.9, - sort_str: r.10, - type_order: r.11, - folder_first: r.12, + created_by: r.10, + updated_by: r.11, + sort_str: r.12, + type_order: r.13, + folder_first: r.14, }) .collect()) } diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index d7b04a68..1475bf8d 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -244,6 +244,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { fld.drive_id AS drive_id, NULL::text AS blob_hash, fld.created_by AS created_by, + fld.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -276,6 +277,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { f.drive_id AS drive_id, f.blob_hash, f.created_by AS created_by, + f.updated_by AS updated_by, EXISTS ( SELECT 1 FROM storage.role_grants g WHERE g.resource_type = 'drive' @@ -454,7 +456,8 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.is_owner, r.accessed_at, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.is_owner, r.accessed_at, r.resource_path, r.sort_str, r.type_order, r.folder_first{username_col} FROM resources r {user_join} @@ -531,6 +534,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), is_owner: row.try_get("is_owner").unwrap_or(false), accessed_at: row.get("accessed_at"), path: row.try_get("resource_path").ok(), diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index a1a9e07f..bcb6d844 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -367,6 +367,8 @@ impl TrashDbRepository { fld.updated_at AS modified_at, fld.drive_id AS drive_id, NULL::text AS blob_hash, + fld.created_by AS created_by, + fld.updated_by AS updated_by, fld.trashed_at AS trashed_at, (fld.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, fld.path::text AS resource_path, @@ -393,6 +395,8 @@ impl TrashDbRepository { f.updated_at AS modified_at, f.drive_id AS drive_id, f.blob_hash, + f.created_by AS created_by, + f.updated_by AS updated_by, f.trashed_at AS trashed_at, (f.trashed_at + ($7::int * INTERVAL '1 day')) AS deletion_date, COALESCE(pfld.path::text || '/' || f.name, f.name) AS resource_path, @@ -522,7 +526,8 @@ impl TrashDbRepository { SELECT r.resource_type, r.resource_id, r.name, r.parent_id, r.mime_type, r.size, r.resource_created_at, r.modified_at, - r.drive_id, r.trashed_at, r.deletion_date, r.resource_path, + r.drive_id, r.blob_hash, r.created_by, r.updated_by, + r.trashed_at, r.deletion_date, r.resource_path, r.sort_str, r.type_order, r.folder_first FROM resources r {keyset} @@ -581,6 +586,8 @@ LIMIT $6" modified_at: row.get("modified_at"), drive_id: row.get("drive_id"), blob_hash: row.try_get("blob_hash").ok(), + created_by: row.try_get("created_by").ok(), + updated_by: row.try_get("updated_by").ok(), trashed_at, deletion_date, path: row.try_get("resource_path").ok(), diff --git a/src/interfaces/api/handlers/favorites_handler.rs b/src/interfaces/api/handlers/favorites_handler.rs index 9c66a523..538cc151 100644 --- a/src/interfaces/api/handlers/favorites_handler.rs +++ b/src/interfaces/api/handlers/favorites_handler.rs @@ -217,9 +217,8 @@ pub async fn list_favorites_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the favorites query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -266,9 +265,8 @@ pub async fn list_favorites_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the favorites query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FavoritesResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index b6d1e964..5956adab 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -487,9 +487,8 @@ pub async fn list_folder_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the resources query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FolderResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -539,9 +538,8 @@ pub async fn list_folder_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the resources query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; FolderResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/src/interfaces/api/handlers/recent_handler.rs b/src/interfaces/api/handlers/recent_handler.rs index 106d0f32..10ad9f43 100644 --- a/src/interfaces/api/handlers/recent_handler.rs +++ b/src/interfaces/api/handlers/recent_handler.rs @@ -237,9 +237,8 @@ pub async fn list_recent_resources( icon_class: intern_display("fas fa-folder"), icon_special_class: intern_display("folder-icon"), category: intern_display("Folder"), - // §14 provenance not selected by the recents query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; RecentResourceItemDto { resource_type: ResourceTypeDto::Folder, @@ -284,9 +283,8 @@ pub async fn list_recent_resources( sort_date: None, content_hash, etag, - // §14 provenance not selected by the recents query. - created_by: None, - updated_by: None, + created_by: row.created_by, + updated_by: row.updated_by, }; RecentResourceItemDto { resource_type: ResourceTypeDto::File, diff --git a/tests/api/grants.hurl b/tests/api/grants.hurl index a292b7fe..941a5091 100644 --- a/tests/api/grants.hurl +++ b/tests/api/grants.hurl @@ -707,6 +707,38 @@ HTTP 200 jsonpath "$.created_by" == "{{alice_user_id}}" jsonpath "$.updated_by" == "{{adam_user_id}}" +# ── D0 §14 provenance survives on the LISTING endpoint too ── +# The rename-response asserts above cover the mutation DTO, but +# /api/folders/{id}/resources has its own DTO-build path that +# used to hardcode created_by/updated_by = None (silent bug — +# owner column rendered "—" on /files for everyone). Hit the +# listing and re-assert both the untouched folder (both = alice) +# AND the Adam-renamed file (created_by=alice, updated_by=adam) +# on the same page — two shapes, one round-trip. +# +# Fixed indices are safe because at this point perm_folder_id +# holds exactly two rows and the default order_by=name puts +# 'perm-test-child' (folder) at [0] and 'adam-renamed-logo.jpg' +# (file) at [1]. Anything appended to this folder later in the +# scenario would break these indices — hence the assertion runs +# BEFORE the subsequent thumbnail/create/upload steps. +GET {{base_url}}/api/folders/{{perm_folder_id}}/resources +Authorization: Bearer {{alice_token}} + +HTTP 200 +[Asserts] +jsonpath "$.items" count == 2 +# [0] — untouched folder inherits Alice on both fields. +jsonpath "$.items[0].resource.name" == "perm-test-child" +jsonpath "$.items[0].resource.created_by" == "{{alice_user_id}}" +jsonpath "$.items[0].resource.updated_by" == "{{alice_user_id}}" +# [1] — file Adam renamed. created_by stays alice (original +# uploader), updated_by is adam (last mutator). Canonical +# listing-side cross-user split. +jsonpath "$.items[1].resource.name" == "adam-renamed-logo.jpg" +jsonpath "$.items[1].resource.created_by" == "{{alice_user_id}}" +jsonpath "$.items[1].resource.updated_by" == "{{adam_user_id}}" + # ── Thumbnail push (Update) succeeds ──────────────────────── PUT {{base_url}}/api/files/{{perm_file_id}}/thumbnail/preview Authorization: Bearer {{adam_token}} From 5b8fb68b30d20984a09d658542d2c8c2ef85f7f3 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 21:16:41 +0200 Subject: [PATCH 15/21] feat(items): clarify column names --- .../src/lib/components/ResourceList.svelte | 27 +++++++++++++------ .../src/lib/styles/ported/resourceList.css | 18 +++++++++---- frontend/src/routes/favorites/+page.svelte | 1 + .../src/routes/files/[...path]/+page.svelte | 2 ++ frontend/src/routes/recent/+page.svelte | 1 + .../src/routes/shared-with-me/+page.svelte | 2 ++ frontend/static/locales/ar.json | 11 ++++++-- frontend/static/locales/de.json | 11 ++++++-- frontend/static/locales/en.json | 9 +++++-- frontend/static/locales/es.json | 11 ++++++-- frontend/static/locales/fa.json | 11 ++++++-- frontend/static/locales/fr.json | 11 ++++++-- frontend/static/locales/hi.json | 11 ++++++-- frontend/static/locales/it.json | 11 ++++++-- frontend/static/locales/ja.json | 11 ++++++-- frontend/static/locales/ko.json | 9 +++++-- frontend/static/locales/nl.json | 11 ++++++-- frontend/static/locales/pl.json | 11 ++++++-- frontend/static/locales/pt.json | 11 ++++++-- frontend/static/locales/ru.json | 11 ++++++-- frontend/static/locales/zh-TW.json | 11 ++++++-- frontend/static/locales/zh.json | 11 ++++++-- 22 files changed, 178 insertions(+), 45 deletions(-) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 83e78d9d..b5259066 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -170,6 +170,14 @@ bucketAction?: Snippet<[string]>; /** Show the owner column + vignette (list view) and hover tooltip. */ showOwner?: boolean; + /** + * Override the owner column header (and the hover-tooltip prefix). The + * default reads "Created by", matching the semantic of `created_by` used + * on /files, /favorites, /recent. /shared-with-me overrides to + * "Shared by" since the column there actually renders `granted_by` + * (the sharer, not the resource author). + */ + ownerLabel?: string; /** Allow grid/list toggle (shares the app-wide view mode). */ showViewToggle?: boolean; /** Show the dotfile-visibility eye toggle in the toolbar AND @@ -362,6 +370,7 @@ dateCell, bucketAction, showOwner = false, + ownerLabel, showViewToggle = true, showDotfileToggle = false, selectable = false, @@ -865,7 +874,7 @@ const owner = ownerId ? (resolveOwnerName?.(ownerId) ?? ownerId) : ''; const path = item.path ?? ''; return [ - owner && `${t('files.col_owner', 'Owner')}: ${owner}`, + owner && `${ownerLabel ?? t('files.col_created_by', 'Created by')}: ${owner}`, path && `${t('files.col_path', 'Location')}: ${path}` ] .filter(Boolean) @@ -1333,13 +1342,15 @@ />
{/if} -
{t('files.col_name', 'Name')}
- {#if showOwner}
{t('files.col_owner', 'Owner')}
{/if} - {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} - {#if showType}
{t('files.col_type', 'Type')}
{/if} - {#if showSize}
{t('files.col_size', 'Size')}
{/if} - {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} - {#if hasActionCell}
{/if} +
{t('files.col_name', 'Name')}
+ {#if showOwner}
+ {ownerLabel ?? t('files.col_created_by', 'Created by')} +
{/if} + {#if showPath}
{pathLabel ?? t('files.col_path', 'Location')}
{/if} + {#if showType}
{t('files.col_type', 'Type')}
{/if} + {#if showSize}
{t('files.col_size', 'Size')}
{/if} + {#if showDate}
{dateLabel ?? t('files.col_modified', 'Date')}
{/if} + {#if hasActionCell}
{/if} {/snippet} diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 47a6ecc2..1de08423 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -266,9 +266,12 @@ min-width: 0; } -/* Size column: always nth-child(5) because .owner-cell is always in the DOM - (even when hidden via display:none, it still occupies a child slot). */ -.list-header > div:nth-child(5), +/* Column alignment — targets classes on BOTH the header divs AND the value + cells, so the header label always matches its column's value alignment + regardless of which optional columns (path/type/owner/…) are on. The + previous shape keyed off `nth-child(N)` and drifted the moment a + ResourceList caller toggled a `show*` prop. */ +.list-header > .size-cell, .files-list-view .file-item .size-cell { justify-self: end; text-align: right; @@ -325,7 +328,12 @@ vignette sized to its content and the cell clipped it flat with no ellipsis. The cell's own `text-overflow` still ellipses plain-text fallback content (cells without a vignette child). */ -.owner-cell { +/* Scoped to `.file-item` so the header div — which also carries the + `.owner-cell` class now (so column-alignment CSS keys off classes + instead of brittle nth-child indices) — doesn't inherit the muted + cell colour / cell font size. Header keeps `.list-header`'s + semibold + text colour. */ +.file-item .owner-cell { color: var(--color-text-secondary); font-size: var(--text-base); display: flex; @@ -427,7 +435,7 @@ flex-shrink: 0; } -.list-header > div:nth-child(5), +.list-header > .date-cell, .files-list-view .file-item .date-cell { justify-self: center; text-align: center; diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index 1f85ba71..895d18f7 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -330,6 +330,7 @@ onfavorite={unfavorite} showOwner showPath + dateLabel={t('files.col_added', 'Added')} selectable {contextActions} menuPrepare={async (item) => { diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index e9ebd219..9a889257 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -1594,6 +1594,8 @@ showOwner showType showDate + dateLabel={t('files.col_modified', 'Modified')} + showPath={false} showDotfileToggle enableSystemDrop onsystemdrop={onDrop} diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index d500a134..4f7f9de9 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -374,6 +374,7 @@ onopen={open} showOwner showPath + dateLabel={t('files.col_opened', 'Opened')} showDotfileToggle selectable {contextActions} diff --git a/frontend/src/routes/shared-with-me/+page.svelte b/frontend/src/routes/shared-with-me/+page.svelte index 7e57274b..c939776b 100644 --- a/frontend/src/routes/shared-with-me/+page.svelte +++ b/frontend/src/routes/shared-with-me/+page.svelte @@ -228,6 +228,8 @@ emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')} hasMore={!!cursor} showOwner={true} + ownerLabel={t('share.col_shared_by', 'Shared by')} + dateLabel={t('share.col_shared', 'Shared')} {groupBys} bind:groupBy bind:reversed diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index bec11111..f1dc557f 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "إشعار عبر البريد الإلكتروني", "revoke": "Remove", - "role_label": "الدور" + "role_label": "الدور", + "col_shared_by": "شورك بواسطة", + "col_shared": "مشترك" }, "share_dialogTitle": "رابط المشاركة", "share_linkLabel": "رابط المشاركة:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "تمت إعادة التسمية إلى \"{{name}}\" — أصبحت الآن مخفية وفقاً لتفضيلاتك.", "new_folder_dotfile_hidden": "تم إنشاء المجلد \"{{name}}\" — مخفي وفقاً لتفضيلاتك.", "dotfiles_hidden_toast": "تم إخفاء الملفات المخفية", - "dotfiles_shown_toast": "تم إظهار الملفات المخفية" + "dotfiles_shown_toast": "تم إظهار الملفات المخفية", + "col_modified": "معدل", + "col_added": "أضيف", + "col_created_by": "أنشئ بواسطة", + "col_opened": "افتُح", + "col_path": "الموقع" }, "dialogs": { "rename_folder": "إعادة تسمية المجلد", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 426ff4d5..9a82bf7e 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Per E-Mail benachrichtigen", "revoke": "Remove", - "role_label": "Rolle" + "role_label": "Rolle", + "col_shared_by": "Geteilt von", + "col_shared": "Geteilt" }, "share_dialogTitle": "Link teilen", "share_linkLabel": "Geteilter Link:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "In \"{{name}}\" umbenannt — jetzt durch Ihre Einstellung ausgeblendet.", "new_folder_dotfile_hidden": "Ordner \"{{name}}\" erstellt — durch Ihre Einstellung ausgeblendet.", "dotfiles_hidden_toast": "Verborgene Dateien ausgeblendet", - "dotfiles_shown_toast": "Verborgene Dateien angezeigt" + "dotfiles_shown_toast": "Verborgene Dateien angezeigt", + "col_modified": "Geändert", + "col_added": "Hinzugefügt", + "col_created_by": "Erstellt von", + "col_opened": "Geöffnet", + "col_path": "Speicherort" }, "dialogs": { "rename_folder": "Ordner umbenennen", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index dace2430..259bb7a8 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -339,7 +339,9 @@ "set_expiry": "Set expiry", "title": "Shared", "unlock": "Unlock", - "role_label": "Role" + "role_label": "Role", + "col_shared_by": "Shared by", + "col_shared": "Shared" }, "share_dialogTitle": "Share Link", "share_linkLabel": "Share Link:", @@ -466,7 +468,10 @@ "batch_delete": "Delete selected", "breadcrumb": "Breadcrumb", "cancel_selection": "Cancel selection", - "col_modified": "Date", + "col_modified": "Modified", + "col_added": "Added", + "col_created_by": "Created by", + "col_opened": "Opened", "col_name": "Name", "col_owner": "Owner", "col_path": "Location", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index f44e243f..91879ff9 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -183,7 +183,9 @@ "link_name": "Nombre del enlace (opcional)", "notifyByEmail": "Notificar por correo", "revoke": "Eliminar", - "role_label": "Rol" + "role_label": "Rol", + "col_shared_by": "Compartido por", + "col_shared": "Compartido" }, "share_dialogTitle": "Compartir Enlace", "share_linkLabel": "Enlace compartido:", @@ -380,7 +382,12 @@ "rename_dotfile_hidden": "Renombrado a \"{{name}}\" — ahora oculto por tu preferencia.", "new_folder_dotfile_hidden": "Carpeta \"{{name}}\" creada — oculta por tu preferencia.", "dotfiles_hidden_toast": "Archivos ocultos ocultados", - "dotfiles_shown_toast": "Archivos ocultos mostrados" + "dotfiles_shown_toast": "Archivos ocultos mostrados", + "col_modified": "Modificado", + "col_added": "Añadido", + "col_created_by": "Creado por", + "col_opened": "Abierto", + "col_path": "Ubicación" }, "dialogs": { "rename_folder": "Renombrar carpeta", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index 0137754c..aa41786b 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "اطلاع‌رسانی از طریق ایمیل", "revoke": "Remove", - "role_label": "نقش" + "role_label": "نقش", + "col_shared_by": "به اشتراک گذاشته شده توسط", + "col_shared": "به اشتراک گذاشته شده" }, "share_dialogTitle": "پیوند هم‌رسانی", "share_linkLabel": "پیوند هم‌رسانی:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "نام به \"{{name}}\" تغییر کرد — اکنون طبق تنظیمات شما پنهان است.", "new_folder_dotfile_hidden": "پوشه \"{{name}}\" ایجاد شد — طبق تنظیمات شما پنهان است.", "dotfiles_hidden_toast": "پرونده‌های پنهان مخفی شد", - "dotfiles_shown_toast": "پرونده‌های پنهان نمایش داده شد" + "dotfiles_shown_toast": "پرونده‌های پنهان نمایش داده شد", + "col_modified": "تغییر یافته", + "col_added": "افزوده شده", + "col_created_by": "ایجاد شده توسط", + "col_opened": "باز شده", + "col_path": "مکان" }, "dialogs": { "rename_folder": "تغییر نام پوشه", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index af817840..9619f3f1 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Notifier par e-mail", "revoke": "Remove", - "role_label": "Rôle" + "role_label": "Rôle", + "col_shared_by": "Partagé par", + "col_shared": "Partagé" }, "share_dialogTitle": "Lien de partage", "share_linkLabel": "Lien partagé :", @@ -375,7 +377,12 @@ "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", - "dotfiles_shown_toast": "Fichiers affichés" + "dotfiles_shown_toast": "Fichiers affichés", + "col_modified": "Modifié", + "col_added": "Ajouté", + "col_created_by": "Créé par", + "col_opened": "Ouvert", + "col_path": "Emplacement" }, "dialogs": { "rename_folder": "Renommer le dossier", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index de4fc33b..12068545 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "ईमेल से सूचित करें", "revoke": "Remove", - "role_label": "भूमिका" + "role_label": "भूमिका", + "col_shared_by": "द्वारा साझा किया गया", + "col_shared": "साझा किया गया" }, "share_dialogTitle": "शेयर लिंक", "share_linkLabel": "शेयर लिंक:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "\"{{name}}\" में नाम बदला — अब आपकी वरीयता के अनुसार छिपा हुआ है।", "new_folder_dotfile_hidden": "फ़ोल्डर \"{{name}}\" बनाया गया — आपकी वरीयता के अनुसार छिपा हुआ है।", "dotfiles_hidden_toast": "छिपी फ़ाइलें छिपाई गईं", - "dotfiles_shown_toast": "छिपी फ़ाइलें दिखाई गईं" + "dotfiles_shown_toast": "छिपी फ़ाइलें दिखाई गईं", + "col_modified": "संशोधित", + "col_added": "जोड़ा गया", + "col_created_by": "द्वारा बनाया गया", + "col_opened": "खोला गया", + "col_path": "स्थान" }, "dialogs": { "rename_folder": "फ़ोल्डर का नाम बदलें", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 8b0a6e4e..888650c3 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Notifica via email", "revoke": "Remuovi", - "role_label": "Ruolo" + "role_label": "Ruolo", + "col_shared_by": "Condiviso da", + "col_shared": "Condiviso" }, "share_dialogTitle": "Link di condivisione", "share_linkLabel": "Link di condivisione:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "Rinominato in \"{{name}}\" — ora nascosto dalla tua preferenza.", "new_folder_dotfile_hidden": "Cartella \"{{name}}\" creata — nascosta dalla tua preferenza.", "dotfiles_hidden_toast": "File nascosti occultati", - "dotfiles_shown_toast": "File nascosti mostrati" + "dotfiles_shown_toast": "File nascosti mostrati", + "col_modified": "Modificato", + "col_added": "Aggiunto", + "col_created_by": "Creato da", + "col_opened": "Aperto", + "col_path": "Posizione" }, "dialogs": { "rename_folder": "Rinomina cartella", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index 90afc95b..fcf883eb 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "メールで通知", "revoke": "Remove", - "role_label": "役割" + "role_label": "役割", + "col_shared_by": "共有者", + "col_shared": "共有日時" }, "share_dialogTitle": "共有リンク", "share_linkLabel": "共有リンク:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "「{{name}}」に名前を変更しました — 設定により非表示になりました。", "new_folder_dotfile_hidden": "フォルダ「{{name}}」を作成しました — 設定により非表示になっています。", "dotfiles_hidden_toast": "非表示ファイルを隠しました", - "dotfiles_shown_toast": "非表示ファイルを表示しました" + "dotfiles_shown_toast": "非表示ファイルを表示しました", + "col_modified": "更新日時", + "col_added": "追加日", + "col_created_by": "作成者", + "col_opened": "アクセス日時", + "col_path": "場所" }, "dialogs": { "rename_folder": "フォルダ名を変更", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index f7247818..90b25a0f 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -304,7 +304,9 @@ "public_link": "공개 링크", "set_expiry": "만료일 설정", "title": "공유됨", - "unlock": "잠금 해제" + "unlock": "잠금 해제", + "col_shared_by": "공유한 사람", + "col_shared": "공유일" }, "share_dialogTitle": "공유 링크", "share_linkLabel": "공유 링크:", @@ -442,7 +444,10 @@ "batch_delete": "선택 항목 삭제", "breadcrumb": "경로", "cancel_selection": "선택 취소", - "col_modified": "날짜", + "col_modified": "수정일", + "col_added": "추가일", + "col_created_by": "만든 사람", + "col_opened": "열어본 날짜", "col_path": "위치", "confirm_batch_delete": "{{n}}개 항목을 휴지통으로 이동하시겠습니까?", "confirm_delete": "\"{{name}}\"을(를) 휴지통으로 이동하시겠습니까?", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 9c85988c..6cea32c5 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Per e-mail notificeren", "revoke": "Remove", - "role_label": "Rol" + "role_label": "Rol", + "col_shared_by": "Gedeeld door", + "col_shared": "Gedeeld" }, "share_dialogTitle": "Deellink", "share_linkLabel": "Deellink:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "Hernoemd naar \"{{name}}\" — nu verborgen door je voorkeur.", "new_folder_dotfile_hidden": "Map \"{{name}}\" aangemaakt — verborgen door je voorkeur.", "dotfiles_hidden_toast": "Verborgen bestanden verborgen", - "dotfiles_shown_toast": "Verborgen bestanden weergegeven" + "dotfiles_shown_toast": "Verborgen bestanden weergegeven", + "col_modified": "Gewijzigd", + "col_added": "Toegevoegd", + "col_created_by": "Gemaakt door", + "col_opened": "Geopend", + "col_path": "Locatie" }, "dialogs": { "rename_folder": "Map hernoemen", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index 4bb34dfa..1e8c93a4 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Powiadom e-mailem", "revoke": "Usuń", - "role_label": "Rola" + "role_label": "Rola", + "col_shared_by": "Udostępnione przez", + "col_shared": "Udostępnione" }, "share_dialogTitle": "Link udostępniania", "share_linkLabel": "Link udostępniania:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "Zmieniono nazwę na \"{{name}}\" — teraz ukryty zgodnie z Twoją preferencją.", "new_folder_dotfile_hidden": "Utworzono folder \"{{name}}\" — ukryty zgodnie z Twoją preferencją.", "dotfiles_hidden_toast": "Ukryte pliki ukryte", - "dotfiles_shown_toast": "Ukryte pliki wyświetlone" + "dotfiles_shown_toast": "Ukryte pliki wyświetlone", + "col_modified": "Zmodyfikowano", + "col_added": "Dodano", + "col_created_by": "Utworzone przez", + "col_opened": "Otwarte", + "col_path": "Lokalizacja" }, "dialogs": { "rename_folder": "Zmień nazwę folderu", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 2cf24b96..ae8fb450 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Notificar por e-mail", "revoke": "Remove", - "role_label": "Função" + "role_label": "Função", + "col_shared_by": "Compartilhado por", + "col_shared": "Compartilhado" }, "share_dialogTitle": "Link de compartilhamento", "share_linkLabel": "Link compartilhado:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "Renomeado para \"{{name}}\" — agora oculto pela sua preferência.", "new_folder_dotfile_hidden": "Pasta \"{{name}}\" criada — oculta pela sua preferência.", "dotfiles_hidden_toast": "Arquivos ocultos ocultados", - "dotfiles_shown_toast": "Arquivos ocultos exibidos" + "dotfiles_shown_toast": "Arquivos ocultos exibidos", + "col_modified": "Modificado", + "col_added": "Adicionado", + "col_created_by": "Criado por", + "col_opened": "Aberto", + "col_path": "Localização" }, "dialogs": { "rename_folder": "Renomear pasta", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 7a3c6fee..991e0ae3 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "Уведомить по e-mail", "revoke": "Remove", - "role_label": "Роль" + "role_label": "Роль", + "col_shared_by": "Поделился", + "col_shared": "Общий доступ" }, "share_dialogTitle": "Ссылка для обмена", "share_linkLabel": "Ссылка:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "Переименовано в \"{{name}}\" — теперь скрыто в соответствии с вашими настройками.", "new_folder_dotfile_hidden": "Папка \"{{name}}\" создана — скрыта в соответствии с вашими настройками.", "dotfiles_hidden_toast": "Скрытые файлы скрыты", - "dotfiles_shown_toast": "Скрытые файлы показаны" + "dotfiles_shown_toast": "Скрытые файлы показаны", + "col_modified": "Изменен", + "col_added": "Добавлено", + "col_created_by": "Создано", + "col_opened": "Открыт", + "col_path": "Расположение" }, "dialogs": { "rename_folder": "Переименовать папку", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 715eff41..5ba9fb19 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "透過郵件通知", "revoke": "移除", - "role_label": "角色" + "role_label": "角色", + "col_shared_by": "分享者", + "col_shared": "分享日期" }, "share_dialogTitle": "共享連結", "share_linkLabel": "共享連結:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "已重新命名為「{{name}}」——現已根據您的偏好隱藏。", "new_folder_dotfile_hidden": "已建立資料夾「{{name}}」——根據您的偏好隱藏。", "dotfiles_hidden_toast": "已隱藏隱藏檔案", - "dotfiles_shown_toast": "已顯示隱藏檔案" + "dotfiles_shown_toast": "已顯示隱藏檔案", + "col_modified": "修改日期", + "col_added": "新增日期", + "col_created_by": "建立者", + "col_opened": "開啟日期", + "col_path": "位置" }, "dialogs": { "rename_folder": "重新命名資料夾", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 579f04cb..21a9ab33 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -234,7 +234,9 @@ "link_name": "Link name (optional)", "notifyByEmail": "通过邮件通知", "revoke": "Remove", - "role_label": "角色" + "role_label": "角色", + "col_shared_by": "共享者", + "col_shared": "共享日期" }, "share_dialogTitle": "共享链接", "share_linkLabel": "共享链接:", @@ -375,7 +377,12 @@ "rename_dotfile_hidden": "已重命名为「{{name}}」——现已根据您的偏好隐藏。", "new_folder_dotfile_hidden": "已创建文件夹「{{name}}」——根据您的偏好隐藏。", "dotfiles_hidden_toast": "已隐藏隐藏文件", - "dotfiles_shown_toast": "已显示隐藏文件" + "dotfiles_shown_toast": "已显示隐藏文件", + "col_modified": "修改日期", + "col_added": "添加日期", + "col_created_by": "创建者", + "col_opened": "打开日期", + "col_path": "位置" }, "dialogs": { "rename_folder": "重命名文件夹", From ae6e3a8eb3ca6977ac87d4dd410a20143bbbcf04 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 21:44:46 +0200 Subject: [PATCH 16/21] feat(items): re-enable lazy loading with cursor example: if a folder has many resource, client will use lazy loading and load next cursor if scroll reached the bottom of the page purpose: reduce the amount of call to server --- .../lib/api/endpoints/folders.bench.test.ts | 221 ------------- frontend/src/lib/api/endpoints/folders.ts | 157 +++++---- .../src/routes/files/[...path]/+page.svelte | 303 +++++++++--------- frontend/src/routes/files/page.test.ts | 43 +-- 4 files changed, 265 insertions(+), 459 deletions(-) delete mode 100644 frontend/src/lib/api/endpoints/folders.bench.test.ts diff --git a/frontend/src/lib/api/endpoints/folders.bench.test.ts b/frontend/src/lib/api/endpoints/folders.bench.test.ts deleted file mode 100644 index d62372db..00000000 --- a/frontend/src/lib/api/endpoints/folders.bench.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; - -vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() })); - -import { apiFetch } from '$lib/api/client'; -import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; -import { fetchFolderListing, invalidateFolderCache, type FolderListing } from './folders'; - -/** - * Benchmark gate for the coalesced progressive-render emissions in - * {@link fetchFolderListing}. - * - * Audit finding: the loader invoked `onPage` after EVERY 200-item page with a - * fresh copy of the whole accumulated listing, and the files view re-derives - * its filtered + sorted view (two `localeCompare` sorts + entry rebuild) from - * each emission. For a folder of N items that is Σ page sizes ≈ O(N²/200) - * elements re-sorted on the main thread during a single load — hundreds of ms - * of jank on exactly the large folders progressive rendering was meant to - * help. The fix emits page one (first paint) and the final page always, and - * intermediate pages at most once per PAGE_EMIT_MIN_INTERVAL_MS. - * - * Gates: - * 1. Equivalence — final listing identical to the emit-every-page reference, - * first emission still after page one (first paint preserved), last - * emission still `done === true` with the complete listing. - * 2. Perf — on a fast connection (pages resolve in ≪150 ms) the consumer-side - * derive work collapses from 25 full re-sorts to ≤3; wall time of the - * load+derive cycle must drop accordingly (≥3x on the derive term). - */ - -type ResourceItem = { resource_type: ItemType; resource: { id: string; name: string } }; -type ResourcePage = { items?: ResourceItem[]; next_cursor?: string }; - -const PAGE_SIZE = 200; -const PAGES = 25; // 5 000-item folder - -/** Deterministic shuffled names so the consumer sort actually works. */ -function pageBody(page: number): ResourcePage { - const items: ResourceItem[] = []; - for (let i = 0; i < PAGE_SIZE; i++) { - const n = page * PAGE_SIZE + i; - const id = `f-${n.toString().padStart(5, '0')}`; - // Mix folders into the first page like a real listing (folders first). - const isFolder = page === 0 && i < 20; - items.push({ - resource_type: isFolder ? 'folder' : 'file', - resource: { id, name: `item ${((n * 7919) % 100000).toString().padStart(5, '0')}.txt` } - }); - } - return { items, next_cursor: page + 1 < PAGES ? `c${page + 1}` : undefined }; -} - -function fakeRes(body: ResourcePage): Response { - return { - status: 200, - ok: true, - json: async () => body, - headers: { get: () => null } - } as unknown as Response; -} - -function mockPagedFetch(): void { - let call = 0; - vi.mocked(apiFetch).mockImplementation(async () => fakeRes(pageBody(call++))); -} - -/** - * The pre-fix loader, verbatim shape: accumulate pages and emit a fresh copy - * of the whole accumulated listing after every page. - */ -async function referenceFetchFolderListing( - folderId: string, - onPage: (partial: FolderListing, done: boolean) => void -): Promise { - const folders: FolderItem[] = []; - const files: FileItem[] = []; - let cursor: string | undefined; - do { - const params = new URLSearchParams({ order_by: 'name', limit: '200' }); - if (cursor) params.set('cursor', cursor); - const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { - credentials: 'same-origin', - cache: 'no-store' - }); - if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const page = (await res.json()) as ResourcePage; - for (const it of page.items ?? []) { - if (it.resource_type === 'folder') folders.push(it.resource as FolderItem); - else files.push(it.resource as FileItem); - } - cursor = page.next_cursor; - onPage({ folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, !cursor); - } while (cursor); - return { folders, files, favoriteIds: [], sharedIds: [] }; -} - -/** - * The files view's per-emission derive chain, reduced to its dominant costs: - * dotfile filter pass + two localeCompare sorts + ordered-entry rebuild - * (`sortedFolders`/`sortedFiles`/`entries`/`orderedIds` in +page.svelte). - * Returns the number of elements that went through the sort — the O(N²) term. - */ -function consumerDerive(partial: FolderListing): number { - const visF = partial.folders.filter((f) => !f.name.startsWith('.')); - const visX = partial.files.filter((f) => !f.name.startsWith('.')); - const sortedF = [...visF].sort((a, b) => a.name.localeCompare(b.name)); - const sortedX = [...visX].sort((a, b) => a.name.localeCompare(b.name)); - const orderedIds = [...sortedF.map((f) => f.id), ...sortedX.map((f) => f.id)]; - return orderedIds.length; -} - -beforeEach(() => { - vi.clearAllMocks(); - invalidateFolderCache(); -}); - -describe('coalesced progressive listing emissions (benchmark gate)', () => { - it('final listing, first-paint page and done-flag match the emit-every-page reference', async () => { - mockPagedFetch(); - const refEmits: Array<{ n: number; done: boolean }> = []; - const refFinal = await referenceFetchFolderListing('bench', (p, done) => - refEmits.push({ n: p.folders.length + p.files.length, done }) - ); - - mockPagedFetch(); - const emits: Array<{ n: number; done: boolean; partial: FolderListing }> = []; - const r = await fetchFolderListing('bench', { - onPage: (partial, done) => - emits.push({ n: partial.folders.length + partial.files.length, done, partial }) - }); - - // Identical complete listing. - expect(r.listing).toEqual(refFinal); - // First paint unchanged: the first emission is still page one. - expect(emits[0].n).toBe(refEmits[0].n); - expect(emits[0].n).toBe(PAGE_SIZE); - // Exactly one done emission, last, carrying the full listing — as before. - expect(emits.filter((e) => e.done).length).toBe(1); - expect(emits[emits.length - 1].done).toBe(true); - expect(emits[emits.length - 1].n).toBe(PAGES * PAGE_SIZE); - expect(refEmits[refEmits.length - 1].done).toBe(true); - // Emissions are a subset of what the reference produced (never more). - expect(emits.length).toBeLessThanOrEqual(refEmits.length); - // Every emitted partial is a prefix-accumulation (monotone growth). - for (let i = 1; i < emits.length; i++) expect(emits[i].n).toBeGreaterThan(emits[i - 1].n); - }); - - it('single-page folders still emit exactly once, done=true (fast path untouched)', async () => { - vi.mocked(apiFetch).mockResolvedValue( - fakeRes({ items: pageBody(PAGES - 1).items }) // no next_cursor - ); - const emits: boolean[] = []; - await fetchFolderListing('one', { onPage: (_p, done) => emits.push(done) }); - expect(emits).toEqual([true]); - }); - - it( - `collapses the O(N²) consumer re-derive on a fast ${PAGES}-page load (perf gate)`, - { timeout: 30_000 }, - async () => { - // Warm-up both paths, twice each, so V8's tiering has fully - // settled before we measure. A single warm-up was enough on - // developer laptops but bursty CPU steals on shared CI - // runners can leave one path un-tiered during measurement, - // skewing the wall-time ratio at line ~202 below. - for (let i = 0; i < 2; i++) { - mockPagedFetch(); - await referenceFetchFolderListing('warm', (p) => consumerDerive(p)); - mockPagedFetch(); - await fetchFolderListing('warm', { onPage: (p) => consumerDerive(p) }); - } - - mockPagedFetch(); - let refSorted = 0; - let refEmits = 0; - const t0 = performance.now(); - await referenceFetchFolderListing('bench', (p) => { - refEmits++; - refSorted += consumerDerive(p); - }); - const refMs = performance.now() - t0; - - mockPagedFetch(); - let sorted = 0; - let emitsN = 0; - const t1 = performance.now(); - await fetchFolderListing('bench', { - onPage: (p) => { - emitsN++; - sorted += consumerDerive(p); - } - }); - const ms = performance.now() - t1; - - console.info( - `progressive load ${PAGES}×${PAGE_SIZE}: before ${refEmits} emissions / ${refSorted} sorted elements / ${refMs.toFixed(1)} ms — after ${emitsN} emissions / ${sorted} sorted elements / ${ms.toFixed(1)} ms (${(refMs / ms).toFixed(1)}x wall, ${(refSorted / sorted).toFixed(1)}x fewer sorted elements)` - ); - - // The reference re-derived every page: Σ = P(P+1)/2 pages of elements. - expect(refEmits).toBe(PAGES); - expect(refSorted).toBe((PAGES * (PAGES + 1) * PAGE_SIZE) / 2); - // Coalesced: page 1 + final (+ occasionally one mid emission if the - // stubbed pages ever take >150 ms — they don't on any healthy runner). - expect(emitsN).toBeLessThanOrEqual(3); - // ≥5x less consumer sort work is the point of the change. - // This is a pure DETERMINISTIC count (sum of `consumerDerive` - // return values) — hardware-independent, so catches an - // actual O(N²) → O(N) regression cleanly. - expect(sorted).toBeLessThan(refSorted / 5); - // And it must show up as wall time on the combined load+ - // derive cycle. 2x floor (loosened from 3x on 2026-07-18 - // after a shared-CI-runner false alarm at 2.63x — bursty - // CPU steals eat headroom on the fine-grained - // `performance.now()` measurements). Still catches an - // O(N²) regression (which would be ~10x slower, not 2x) - // — the deterministic count above at line 200 is the real - // algorithmic gate. - expect(ms).toBeLessThan(refMs / 2); - } - ); -}); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 19d1f947..0ceb996e 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -110,86 +110,109 @@ export function getFolder(id: string): Promise { return request; } -/** - * Minimum spacing between intermediate progressive-render emissions of - * {@link fetchFolderListing}. Each emission hands the consumer the WHOLE - * accumulated listing, and the files view re-derives its filtered + sorted - * view from it (O(accumulated · log) with `localeCompare`), so emitting every - * page made a large-folder load Σ O(N²/page) of main-thread sort work. Page - * one and the final page always emit; pages in between only emit after this - * much time has passed since the previous emission. - */ -export const PAGE_EMIT_MIN_INTERVAL_MS = 150; +/** One page of `/api/folders/{id}/resources`. */ +export interface FolderPage { + /** + * Items in the exact order the server returned them. Under `order_by=name`, + * `type`, `size` the server puts folders first, then files; under + * `modified_at` / `created_at` the two kinds interleave. Consumers that + * need to preserve the server sort MUST iterate this list — the split + * `folders` / `files` arrays lose the interleaving. + */ + items: (FolderItem | FileItem)[]; + /** `items` filtered to folder rows (order preserved). */ + folders: FolderItem[]; + /** `items` filtered to file rows (order preserved). */ + files: FileItem[]; + /** Opaque cursor for the next page; `undefined` on the last page. */ + nextCursor?: string; +} /** - * Fetch a folder's complete listing (sub-folders + files), rebuilt from the - * cursor-paginated `/api/folders/{id}/resources` feed — the old combined - * `/listing` route was removed. We page through to the end (folders sort first - * under `order_by=name`) and split the mixed resource items back into - * `folders` / `files`. + * Fetch a single page of a folder's listing. * - * That feed carries no whole-listing ETag, so the 304 conditional fast-path is - * gone: `opts.etag` is accepted for call-site compatibility but ignored, and the - * in-memory `folderCache` is what the views revalidate against. Favorite/share - * badge sets aren't part of this feed either, so they come back empty for now. + * `/files` uses this directly and drives its own pagination — the initial + * `load()` requests page one; the ResourceList's `onloadmore` (fired by an + * IntersectionObserver at the bottom sentinel) requests the next page with + * the previous `nextCursor` and appends the results. `orderBy` is passed + * through so pages come back in the requested server-side sort order; the + * caller resets state and refetches page one on sort/group change. + * + * The legacy `fetchFolderListing` (below) is a thin loop over this — kept + * for the move-dialog folder tree, which genuinely needs every child at + * once and doesn't have an infinite-scroll surface. + */ +export async function fetchFolderPage( + folderId: string, + opts: { + orderBy?: string; + reverse?: boolean; + cursor?: string; + limit?: number; + forceRefresh?: boolean; + } = {} +): Promise { + const params = new URLSearchParams({ + order_by: opts.orderBy ?? 'name', + limit: String(opts.limit ?? 200) + }); + if (opts.reverse) params.set('reverse', 'true'); + if (opts.cursor) params.set('cursor', opts.cursor); + if (opts.forceRefresh) params.set('force_refresh', 'true'); + const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { + credentials: 'same-origin', + cache: 'no-store' + }); + if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); + if (!res.ok) throw new Error(`listing failed: ${res.status}`); + const page = (await res.json()) as { + items?: { resource_type: ItemType; resource: FolderItem | FileItem }[]; + next_cursor?: string; + }; + const items: (FolderItem | FileItem)[] = []; + const folders: FolderItem[] = []; + const files: FileItem[] = []; + for (const it of page.items ?? []) { + if (it.resource_type === 'folder') { + const f = it.resource as FolderItem; + folders.push(f); + items.push(f); + } else { + const f = it.resource as FileItem; + files.push(f); + items.push(f); + } + } + // Learn the children's names for breadcrumb resolution. + for (const f of folders) rememberFolderName(f.id, f.name); + return { items, folders, files, nextCursor: page.next_cursor }; +} + +/** + * Fetch a folder's complete listing (sub-folders + files) by walking every + * cursor page eagerly. Only the move-dialog tree still needs this shape — + * `/files` switched to {@link fetchFolderPage} for lazy scroll-driven paging. + * + * `opts.etag` is accepted for call-site compatibility but ignored (the + * `/resources` feed carries no whole-listing ETag). Favorite / share badge + * sets are unpopulated by this endpoint and come back empty. */ export async function fetchFolderListing( folderId: string, - opts: { - etag?: string; - forceRefresh?: boolean; - /** - * Progressive render hook: invoked with the accumulated listing so - * far (the arrays are fresh copies — safe to hand to reactive - * state). Without it, a 2,000-item folder waited for all ⌈N/200⌉ - * sequential round-trips before the first row painted; with it the - * view paints after page one (~200 items) and fills in as the tail - * pages land. Emissions are coalesced to at most one per - * {@link PAGE_EMIT_MIN_INTERVAL_MS} between the first and the final - * page — the hook is always called for page one and always called - * once more with `done === true` and the complete listing. - */ - onPage?: (partial: FolderListing, done: boolean) => void; - } = {} + opts: { etag?: string; forceRefresh?: boolean } = {} ): Promise { const folders: FolderItem[] = []; const files: FileItem[] = []; let cursor: string | undefined; - let firstPage = true; - let lastEmit = 0; do { - const params = new URLSearchParams({ order_by: 'name', limit: '200' }); - if (opts.forceRefresh) params.set('force_refresh', 'true'); - if (cursor) params.set('cursor', cursor); - const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, { - credentials: 'same-origin', - cache: 'no-store' + const page = await fetchFolderPage(folderId, { + cursor, + forceRefresh: opts.forceRefresh }); - if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 }); - if (!res.ok) throw new Error(`listing failed: ${res.status}`); - const page = (await res.json()) as { - items?: { resource_type: ItemType; resource: FolderItem | FileItem }[]; - next_cursor?: string; - }; - for (const it of page.items ?? []) { - if (it.resource_type === 'folder') folders.push(it.resource as FolderItem); - else files.push(it.resource as FileItem); - } - cursor = page.next_cursor; - const done = !cursor; - if ( - opts.onPage && - (done || firstPage || performance.now() - lastEmit >= PAGE_EMIT_MIN_INTERVAL_MS) - ) { - lastEmit = performance.now(); - opts.onPage( - { folders: [...folders], files: [...files], favoriteIds: [], sharedIds: [] }, - done - ); - } - firstPage = false; + folders.push(...page.folders); + files.push(...page.files); + cursor = page.nextCursor; } while (cursor); - return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } }; } diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 9a889257..8bc2d309 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -7,11 +7,9 @@ import { SvelteSet } from 'svelte/reactivity'; import Icon from '$lib/icons/Icon.svelte'; import { - cacheFolder, createFolder, deleteFolder, - fetchFolderListing, - getCachedFolder, + fetchFolderPage, getFolder, getFolderName, invalidateFolderCache, @@ -110,17 +108,26 @@ }); let listing = $state({ folders: [], files: [], favoriteIds: [], sharedIds: [] }); + // Server-order accumulator — items in the exact sequence the backend + // returned across pages, honouring `sortField`+`reversed` on the wire. + // Under order_by=name/type/size the server puts folders first then files; + // under modified_at/created_at they interleave. `rlItems` reads this + // directly so ResourceList renders in server order without a re-sort. + let orderedItems = $state>([]); + // Cursor for the NEXT page. `undefined` after the final page has landed + // (or before the first fetch). Bound to ResourceList's `hasMore`. + let pageCursor = $state(undefined); + // Guard so a fast-firing onloadmore (double intersection tick) can't + // enqueue two concurrent next-page fetches on the same cursor. + let loadingMore = $state(false); - // Dotfile hide filter — applied BEFORE sort so `sortedFolders` / - // `sortedFiles` reflect exactly what the user sees. Selection, - // select-all, batch operations, and the empty-state check all - // derive from these visible arrays so a hidden file can't be - // silently swept up by "select all" or a "delete visible" batch. - // Direct lookups by id (deep-links via `?file=`) still go - // through `listing.files` so hidden files remain accessible by - // their own URL — same UX as macOS Finder. - const visibleFolders = $derived(filterDotfiles(listing.folders, preferences.hideDotfiles)); - const visibleFiles = $derived(filterDotfiles(listing.files, preferences.hideDotfiles)); + // Dotfile hide filter is now applied inside `rlItems` (below) directly + // on the server-ordered accumulator, so a single filter pass feeds + // ResourceList. Selection / batch ops iterate ResourceList's own + // selection set, which already excludes hidden rows. Direct lookups + // by id (deep-links via `?file=`) still go through + // `listing.files` so hidden files remain reachable via their own URL + // — same UX as macOS Finder. // Count of items suppressed by the filter — surfaced in the // empty-state hint when the folder isn't visually empty but // contains only dotfiles the user has hidden, so a "why is this @@ -211,135 +218,157 @@ // writes state, so a fast navigation can't be clobbered by an older fetch. let loadSeq = 0; - function applyListing(data: FolderListing) { - listing = data; - replaceSet(favoriteIds, data.favoriteIds); - replaceSet(sharedIds, data.sharedIds); - } - - async function load() { + /** + * Load the current folder's listing. + * + * @param reset Fresh load (folder nav / sort change / manual reload): + * clears cursor+accumulator, redoes canonicalization + + * breadcrumbs, then fetches page 1. + * + * Append (from `loadMore()` on scroll-bottom): skips + * preconditions, fetches the NEXT page using the stored + * cursor and appends to `listing`+`orderedItems`. + * + * Server-side sort: `orderBy=sortField, reverse=reversed` are passed on + * every page request so items arrive already in the requested order — + * client-side sort was removed and `rlItems` reads `orderedItems` + * verbatim. Sort/group changes trigger `load(true)` via `$effect`. + */ + async function load(reset: boolean = true) { error = null; const seq = ++loadSeq; - // External users have no home folder; send them to shared-with-me. - if (session.isExternalUser && pathSegments.length === 0) { - await goto(resolve('/shared-with-me'), { replaceState: true }); - return; - } - const home = await session.loadHomeFolder(); - - // Canonicalize bare `/files` → `/files/` (or - // the default drive's root when there's no memory yet). Keeps the URL - // explicit, the breadcrumb populated, and the drive picker correctly - // highlighted. The DrivePicker writes `oxi-last-drive-root` on click. - if (pathSegments.length === 0) { - const last = - typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; - const target = last ?? home; - if (target) { - await goto(resolve(`/files/${target}`), { replaceState: true }); + let folderId: string; + let skeletonTimer: ReturnType | undefined; + if (reset) { + // External users have no home folder; send them to shared-with-me. + if (session.isExternalUser && pathSegments.length === 0) { + await goto(resolve('/shared-with-me'), { replaceState: true }); return; } - } + const home = await session.loadHomeFolder(); - const folderId = pathSegments.at(-1) ?? home; - if (!folderId) { - error = t('files.no_home', 'No home folder available.'); - return; - } - currentId = folderId; - filesStore.currentFolder = folderId; + // Canonicalize bare `/files` → `/files/` (or + // the default drive's root when there's no memory yet). Keeps the URL + // explicit, the breadcrumb populated, and the drive picker correctly + // highlighted. The DrivePicker writes `oxi-last-drive-root` on click. + if (pathSegments.length === 0) { + const last = + typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null; + const target = last ?? home; + if (target) { + await goto(resolve(`/files/${target}`), { replaceState: true }); + return; + } + } - // Stale-while-revalidate: paint a previously-visited folder instantly, - // then revalidate with If-None-Match (304 = keep what's shown). - const cached = getCachedFolder(folderId); - if (cached) { - applyListing(cached.listing); - loading = false; - showSkeleton = false; - } else { + const resolvedId = pathSegments.at(-1) ?? home; + if (!resolvedId) { + error = t('files.no_home', 'No home folder available.'); + return; + } + folderId = resolvedId; + currentId = folderId; + filesStore.currentFolder = folderId; + + // Reset paging state: previous folder's cursor is meaningless here, + // and mixing its rows with the new folder's would flash a wrong list. + pageCursor = undefined; + listing = { folders: [], files: [], favoriteIds: [], sharedIds: [] }; + orderedItems = []; loading = true; - } - // Delayed skeleton, only when there's nothing cached to show yet. - const skeletonTimer = setTimeout(() => { - if (loading) showSkeleton = true; - }, 100); - // Breadcrumbs resolve independently so they never block the grid paint. - // Bare `/files` was canonicalized above to `/files/` so pathSegments - // is always non-empty here for internal users. - void buildCrumbs(pathSegments).then((trail) => { - if (seq === loadSeq) crumbs = trail; - }); + // Delayed skeleton so fast loads don't flash it. + skeletonTimer = setTimeout(() => { + if (loading) showSkeleton = true; + }, 100); - // Resolve the current folder's drive_id so the read-only banner - // works even on deep-links into a sub-folder (where - // `pathSegments[0]` isn't a drive-root folder id). `getFolder` - // hits the same `/api/folders/{id}` endpoint the breadcrumb chain - // walks; the folder-name cache warmed by `buildCrumbs` above - // makes this a memoised lookup for most navigations. Guarded by - // `seq` so a stale in-flight response can't overwrite a newer - // navigation's drive. - void getFolder(folderId) - .then((folder) => { - if (seq === loadSeq) currentFolderDriveId = folder.drive_id; - }) - .catch(() => { - // Folder metadata fetch failure isn't fatal — the fallback - // chain in `currentDrive` (listing[0]?.drive_id, then - // pathSegments[0] root-folder lookup) still gives us a - // best-effort drive resolution. + // Breadcrumbs resolve independently so they never block the grid paint. + void buildCrumbs(pathSegments).then((trail) => { + if (seq === loadSeq) crumbs = trail; }); + // Resolve the current folder's drive_id so the read-only banner + // works even on deep-links into a sub-folder. Guarded by `seq`. + void getFolder(folderId) + .then((folder) => { + if (seq === loadSeq) currentFolderDriveId = folder.drive_id; + }) + .catch(() => { + // Fallback chain in `currentDrive` still gives us a + // best-effort drive resolution. + }); + } else { + // Append path: reuse `currentId`. `pageCursor === undefined` means + // we've already reached the last page; treat as no-op. + if (!currentId || pageCursor === undefined) return; + folderId = currentId; + } + try { - const res = await fetchFolderListing(folderId, { - etag: cached?.etag, - // Paint page one (~200 items) immediately instead of waiting - // for every sequential page of a large folder; later pages - // extend the view as they land. Skip when a cached copy is - // already on screen — replacing it with a partial list would - // briefly shrink the view. - onPage: cached - ? undefined - : (partial, done) => { - if (seq !== loadSeq || done) return; // final state applied below - applyListing(partial); - loading = false; - showSkeleton = false; - } + const page = await fetchFolderPage(folderId, { + orderBy: sortField, + reverse: reversed, + cursor: reset ? undefined : pageCursor }); if (seq !== loadSeq) return; // superseded by a newer navigation - if (res.status === 200 && res.listing) { - applyListing(res.listing); - cacheFolder(folderId, res.listing, res.etag); + if (reset) { + listing = { + folders: page.folders, + files: page.files, + favoriteIds: [], + sharedIds: [] + }; + orderedItems = page.items; + } else { + listing = { + folders: [...listing.folders, ...page.folders], + files: [...listing.files, ...page.files], + favoriteIds: listing.favoriteIds, + sharedIds: listing.sharedIds + }; + orderedItems = [...orderedItems, ...page.items]; } - // 304 → the cached copy already on screen is current. + pageCursor = page.nextCursor; error = null; } catch (e) { if (seq !== loadSeq) return; - // With a cached view already shown, keep it on a transient failure. - if (!cached) { - const status = (e as { status?: number })?.status; - error = - status === 403 - ? t('errors.forbidden', 'Could not load files') - : e instanceof Error - ? e.message - : String(e); - } + const status = (e as { status?: number })?.status; + error = + status === 403 + ? t('errors.forbidden', 'Could not load files') + : e instanceof Error + ? e.message + : String(e); } finally { - clearTimeout(skeletonTimer); - if (seq === loadSeq) { + if (skeletonTimer !== undefined) clearTimeout(skeletonTimer); + if (seq === loadSeq && reset) { loading = false; showSkeleton = false; } } } + /** + * Fetch and append the next page. Invoked by ResourceList's + * IntersectionObserver when the bottom sentinel enters the viewport. + * The `loadingMore` guard collapses a double-fire (the observer can + * tick twice on the same intersection edge). + */ + async function loadMore() { + if (loadingMore || pageCursor === undefined) return; + loadingMore = true; + try { + await load(false); + } finally { + loadingMore = false; + } + } + /** Data changed — drop cached listings and reload the current folder fresh. */ async function reload() { invalidateFolderCache(); - await load(); + await load(true); } function openFolder(folder: FolderItem) { @@ -1382,35 +1411,14 @@ type SortField = 'name' | 'type' | 'size' | 'modified_at' | 'created_at'; let sortField = $state('name'); let reversed = $state(false); - const sortDir = $derived<1 | -1>(reversed ? -1 : 1); - function cmpFolders(a: FolderItem, b: FolderItem): number { - let v: number; - if (sortField === 'modified_at') v = a.modified_at - b.modified_at; - else if (sortField === 'created_at') v = a.created_at - b.created_at; - // Folders have no size; fall back to name for size/type so they stay stable. - else v = a.name.localeCompare(b.name); - return v * sortDir; - } - function cmpFiles(a: FileItem, b: FileItem): number { - let v: number; - if (sortField === 'size') v = (a.size ?? 0) - (b.size ?? 0); - else if (sortField === 'modified_at') v = a.modified_at - b.modified_at; - else if (sortField === 'created_at') v = a.created_at - b.created_at; - else if (sortField === 'type') v = (a.category ?? '').localeCompare(b.category ?? ''); - else v = a.name.localeCompare(b.name); - return v * sortDir; - } - - // Sorted (folders-then-files) merged into one `Array` - // that renders directly. Order matches the un-migrated - // layout: folders precede files, sort key applied within each cohort. The - // bespoke `Entry` discriminator + swimlane bucketing that used to live - // here is gone — ResourceList does swimlane bucketing itself via - // `rlGroupBys` below. - const sortedFolders = $derived([...visibleFolders].sort(cmpFolders)); - const sortedFiles = $derived([...visibleFiles].sort(cmpFiles)); - const rlItems = $derived>([...sortedFolders, ...sortedFiles]); + // Server does the sort (order_by=sortField, reverse=reversed on every + // page request), so ResourceList reads `orderedItems` in server order + // straight through the dotfile filter. No client-side comparator + // necessary. Under order_by=name/type/size the server puts folders + // first then files; under modified_at/created_at they interleave — + // preserving the accumulator order is what surfaces that correctly. + const rlItems = $derived(filterDotfiles(orderedItems, preferences.hideDotfiles)); // Group-by state (bound to ). Kept as a `string` prop // value; the current `sortField` mirrors from the picked group's @@ -1508,7 +1516,8 @@ return () => window.removeEventListener('pointerdown', onDown); }); - // Reload whenever the route path changes. + // Reload whenever the route path OR the server sort dimension/direction + // changes. // // `load()` reads several reactive signals in its sync phase // (session.isExternalUser, session.homeFolderId, plus whatever @@ -1517,12 +1526,14 @@ // `session.loadHomeFolder()`'s own writes to `homeFolderId` // during its resolution then re-trigger the effect, firing a // second and third `load()` before the first has settled. Wrap - // in `untrack` so the ONLY dependency is `pathSegments` (route - // change is the sole legitimate re-trigger). + // in `untrack` so the ONLY dependencies are the three we WANT + // to reload on: pathSegments, sortField, reversed. $effect(() => { void pathSegments; + void sortField; + void reversed; untrack(() => { - void load(); + void load(true); }); }); @@ -1602,6 +1613,8 @@ groupBys={rlGroupBys} bind:groupBy bind:reversed + hasMore={pageCursor !== undefined} + onloadmore={loadMore} onreload={(orderBy) => { sortField = orderBy as SortField; }} diff --git a/frontend/src/routes/files/page.test.ts b/frontend/src/routes/files/page.test.ts index 8e4e4545..7ebc667a 100644 --- a/frontend/src/routes/files/page.test.ts +++ b/frontend/src/routes/files/page.test.ts @@ -46,12 +46,10 @@ vi.mock('$lib/api/endpoints/files', () => ({ uploadFileWithProgress: vi.fn() })); vi.mock('$lib/api/endpoints/folders', () => ({ - cacheFolder: vi.fn(), createFolder: vi.fn(), deleteFolder: vi.fn(), - fetchFolderListing: vi.fn(), + fetchFolderPage: vi.fn(), folderZipUrl: () => '/zip', - getCachedFolder: () => undefined, getFolder: vi.fn(async (id: string) => ({ id, name: id })), getFolderName: () => undefined, invalidateFolderCache: vi.fn(), @@ -60,7 +58,7 @@ vi.mock('$lib/api/endpoints/folders', () => ({ renameFolder: vi.fn() })); -import { fetchFolderListing, createFolder, deleteFolder } from '$lib/api/endpoints/folders'; +import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders'; import { deleteFile } from '$lib/api/endpoints/files'; import { apiFetch } from '$lib/api/client'; import { files as filesStore } from '$lib/stores/files.svelte'; @@ -69,15 +67,17 @@ import FilesPage from './[...path]/+page.svelte'; const m = (fn: unknown) => fn as ReturnType; function withListing() { - m(fetchFolderListing).mockResolvedValue({ - status: 200, - etag: 'v1', - listing: { - folders: [folderItem('sub1', 'Sub')], - files: [fileItem('f1', 'hello.txt')], - favoriteIds: [], - sharedIds: [] - } + // `fetchFolderPage` returns ONE page with the accumulator shape (items in + // server order + folders/files splits). With `nextCursor` omitted the + // caller treats it as the last page — the page's items become the whole + // on-screen listing without triggering `loadMore`. + const folder = folderItem('sub1', 'Sub'); + const file = fileItem('f1', 'hello.txt'); + m(fetchFolderPage).mockResolvedValue({ + items: [folder, file], + folders: [folder], + files: [file], + nextCursor: undefined }); } @@ -131,27 +131,18 @@ beforeEach(() => { }); it('loads the home folder listing on mount and renders its contents', async () => { - m(fetchFolderListing).mockResolvedValue({ - status: 200, - etag: 'v1', - listing: { - folders: [folderItem('sub1', 'Sub')], - files: [fileItem('f1', 'hello.txt')], - favoriteIds: [], - sharedIds: [] - } - }); + withListing(); render(FilesPage); - await waitFor(() => expect(fetchFolderListing).toHaveBeenCalledWith('home', expect.anything())); + await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledWith('home', expect.anything())); // VirtualList windows rows by viewport height (0 in jsdom), so assert the // surrounding chrome rendered rather than the windowed rows themselves. await screen.findByTestId('files-new-folder-btn'); }); it('shows an error when the listing fails with no cache', async () => { - m(fetchFolderListing).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 })); + m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 })); render(FilesPage); - await waitFor(() => expect(fetchFolderListing).toHaveBeenCalled()); + await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled()); }); it('redirects external users away from the home folder', async () => { From a520afcf7c9ca15da34e3a97eea90dc808683aa7 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 22:03:08 +0200 Subject: [PATCH 17/21] feat(ui:items): uploading an item with a swimlane display restore the legacy display with new element uploaded, when swimlane is in place as the sort is done by server side just add new element in a "new elements" swimlane. if user continue to scroll down in cursor/pages and item is found from server, UI remove it from the new element and restore the position other option: is user refresh it's page, server will restore the natural order --- .../src/lib/components/ResourceList.svelte | 36 ++--- .../src/routes/files/[...path]/+page.svelte | 136 ++++++++++++++++-- frontend/static/locales/ar.json | 3 +- frontend/static/locales/de.json | 3 +- frontend/static/locales/en.json | 1 + frontend/static/locales/es.json | 3 +- frontend/static/locales/fa.json | 3 +- frontend/static/locales/fr.json | 3 +- frontend/static/locales/hi.json | 3 +- frontend/static/locales/it.json | 3 +- frontend/static/locales/ja.json | 3 +- frontend/static/locales/ko.json | 1 + frontend/static/locales/nl.json | 3 +- frontend/static/locales/pl.json | 3 +- frontend/static/locales/pt.json | 3 +- frontend/static/locales/ru.json | 3 +- frontend/static/locales/zh-TW.json | 3 +- frontend/static/locales/zh.json | 3 +- 18 files changed, 172 insertions(+), 44 deletions(-) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index b5259066..ba645ee7 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -1239,14 +1239,16 @@
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if} {#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - - {/if} -
+ {#if section.label} +
+ {section.label} + {#if bucketAction} + + {@render bucketAction(section.key)} + + {/if} +
+ {/if} e.id} {row} /> @@ -1262,14 +1264,16 @@ (benches/ROUND13.md §V1). -->
{#each sections as section (section.key)} -
- {section.label} - {#if bucketAction} - - {@render bucketAction(section.key)} - - {/if} -
+ {#if section.label} +
+ {section.label} + {#if bucketAction} + + {@render bucketAction(section.key)} + + {/if} +
+ {/if} (); + // Dotfile hide filter is now applied inside `rlItems` (below) directly // on the server-ordered accumulator, so a single filter pass feeds // ResourceList. Selection / batch ops iterate ResourceList's own @@ -371,6 +391,36 @@ await load(true); } + /** + * Reload + populate the "new elements" swimlane with anything that + * appeared on page 1 after the mutation. + * + * Called from mutation paths that ADD items (upload / dropped tree / + * create-folder). Renames, deletes, moves use plain `reload()` + * — nothing new to hoist. + */ + async function reloadAndTrackNew(): Promise { + const before = new SvelteSet(); + for (const it of orderedItems) before.add(it.id); + await reload(); + // `reload()` resets `pageCursor` + fetches page 1 fresh, so + // `orderedItems` is now the freshly-loaded page. Every id that + // wasn't there before this reload joins the swimlane. + newlyAdded.clear(); + for (const it of orderedItems) if (!before.has(it.id)) newlyAdded.add(it.id); + // Scroll the page back to the top so the freshly-hoisted "New + // elements" swimlane is visible without the user having to hunt + // for it — the whole point of the swimlane is to confirm "your + // upload landed". Only fires when we actually detected new items, + // so a bare reload doesn't yank the user's scroll position. + // Smooth scroll for the visual continuity — instant would feel + // like the page reloaded. `scrollTo` at (0, 0) is a no-op if + // the user was already at the top; no jitter cost. + if (newlyAdded.size > 0 && typeof window !== 'undefined') { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } + } + function openFolder(folder: FolderItem) { goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`)); } @@ -384,7 +434,7 @@ if (!name) return; try { await createFolder(name, currentId); - await reload(); + await reloadAndTrackNew(); // Vanish-warning: user just made a `.folder` and it's // already hidden by their preference — otherwise the new // folder would appear to have not been created. Third hook @@ -668,7 +718,7 @@ } else { finishUpload(nid, 0, 0, 0, skipped.length); } - await reload(); + await reloadAndTrackNew(); // Storage usage changed server-side — pull the fresh figure so the // "Almacenamiento" bar moves off its login value instead of 0%. void session.refresh(); @@ -1375,7 +1425,7 @@ const { savedBytes, failures } = await uploadAll(items, nid, label); finishUpload(nid, savedBytes, failures, total, skipped.length); - await reload(); + await reloadAndTrackNew(); void session.refresh(); } catch (err) { ui.finishProgress(nid, errorMessage(err), 'error'); @@ -1418,7 +1468,25 @@ // necessary. Under order_by=name/type/size the server puts folders // first then files; under modified_at/created_at they interleave — // preserving the accumulator order is what surfaces that correctly. - const rlItems = $derived(filterDotfiles(orderedItems, preferences.hideDotfiles)); + // + // Hoist step: items in `newlyAdded` (populated by `reloadAndTrackNew` + // after an upload / create / dropped tree) are pulled OUT of their + // natural-order position and PREPENDED to the list, so the + // "__new__" bucket rendered by the composed groupBy below appears + // at the top of the swimlanes regardless of what sort/group the + // user has active. First-appearance bucketing in + // `buildResourceSections` keys off the item order in the input list. + const rlItems = $derived.by>(() => { + const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles); + if (newlyAdded.size === 0) return filtered; + const hoisted: Array = []; + const rest: Array = []; + for (const it of filtered) { + if (newlyAdded.has(it.id)) hoisted.push(it); + else rest.push(it); + } + return [...hoisted, ...rest]; + }); // Group-by state (bound to ). Kept as a `string` prop // value; the current `sortField` mirrors from the picked group's @@ -1429,40 +1497,75 @@ // modifiedAt / createdAt). The `orderBy` values are what the // GROUP_BYS toolbar emits, so 's onreload gets the // legacy `sortField` name and can drive the same sort path. + // + // Every dimension composes a `__new__` branch on top of its natural + // `bucketOf` so that whenever the transient "new elements" swimlane + // is active, hoisted items get their own bucket-first-in-order + // regardless of the user's chosen group. On the default `''` (flat) + // dimension the wrapped `bucketOf` returns the empty string for + // non-new items — that renders as one unlabeled section (header + // suppressed by ResourceList when `label === ''`), preserving the + // current flat-list look with just the "New elements" header on + // top. `labelForNew` renders the localised header. + const NEW_KEY = '__new__'; + const labelForNew = $derived(t('files.new_elements', 'New elements')); + const wrapNew = + (inner?: (item: T) => string | null) => + (item: T): string | null => { + if (newlyAdded.has(item.id)) return NEW_KEY; + return inner ? inner(item) : ''; + }; + const wrapLabel = + (inner?: (key: string) => string) => + (key: string): string => { + if (key === NEW_KEY) return labelForNew; + return inner ? inner(key) : key; + }; const rlGroupBys = $derived([ - { key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' }, + { + key: '', + label: t('files.name', 'Name'), + orderBy: 'name', + icon: 'arrow-up-a-z', + // Only synthesize a bucketOf when the swimlane is active; when + // no new items exist we want the plain flat-list rendering + // (no bucketing pass at all). + bucketOf: newlyAdded.size > 0 ? wrapNew() : undefined, + labelOf: newlyAdded.size > 0 ? wrapLabel() : undefined + }, { key: 'type', label: t('groupby.type', 'Type'), orderBy: 'type', icon: 'layer-group', - bucketOf: (item) => - isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'), - labelOf: (k) => k + bucketOf: wrapNew((item) => + isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders') + ), + labelOf: wrapLabel((k) => k) }, { key: 'size', label: t('groupby.size', 'Size'), orderBy: 'size', icon: 'layer-group', - bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)), - labelOf: (k) => k + bucketOf: wrapNew((item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1))), + labelOf: wrapLabel((k) => k) }, { key: 'modifiedAt', label: t('groupby.modifiedAt', 'Modified date'), orderBy: 'modified_at', icon: 'layer-group', - bucketOf: (item) => dateBucket(item.modified_at), - labelOf: (k) => k + bucketOf: wrapNew((item) => dateBucket(item.modified_at)), + labelOf: wrapLabel((k) => k) }, { key: 'createdAt', label: t('groupby.createdAt', 'Created date'), orderBy: 'created_at', icon: 'layer-group', - bucketOf: (item) => dateBucket(item.created_at), - labelOf: (k) => k + bucketOf: wrapNew((item) => dateBucket(item.created_at)), + labelOf: wrapLabel((k) => k) } ]); @@ -1533,6 +1636,11 @@ void sortField; void reversed; untrack(() => { + // Route/sort change → drop the transient "new elements" + // swimlane. It's a per-folder confirmation of "here's what + // you just added"; carrying it across folders would surface + // stale ids that don't belong to the new listing. + newlyAdded.clear(); void load(true); }); }); diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index f1dc557f..00fcd333 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -382,7 +382,8 @@ "col_added": "أضيف", "col_created_by": "أنشئ بواسطة", "col_opened": "افتُح", - "col_path": "الموقع" + "col_path": "الموقع", + "new_elements": "عناصر جديدة" }, "dialogs": { "rename_folder": "إعادة تسمية المجلد", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 9a82bf7e..5b3b6915 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -382,7 +382,8 @@ "col_added": "Hinzugefügt", "col_created_by": "Erstellt von", "col_opened": "Geöffnet", - "col_path": "Speicherort" + "col_path": "Speicherort", + "new_elements": "Neue Elemente" }, "dialogs": { "rename_folder": "Ordner umbenennen", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 259bb7a8..3438c366 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -504,6 +504,7 @@ "moved": "Moved", "new_folder": "New folder", "new_folder_prompt": "New folder name", + "new_elements": "New elements", "no_home": "No home folder available.", "no_preview": "No preview available for this file type.", "no_subfolders": "No subfolders here.", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index 91879ff9..5e959f44 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -387,7 +387,8 @@ "col_added": "Añadido", "col_created_by": "Creado por", "col_opened": "Abierto", - "col_path": "Ubicación" + "col_path": "Ubicación", + "new_elements": "Nuevos elementos" }, "dialogs": { "rename_folder": "Renombrar carpeta", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index aa41786b..8a4f90d1 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -382,7 +382,8 @@ "col_added": "افزوده شده", "col_created_by": "ایجاد شده توسط", "col_opened": "باز شده", - "col_path": "مکان" + "col_path": "مکان", + "new_elements": "موارد جدید" }, "dialogs": { "rename_folder": "تغییر نام پوشه", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index 9619f3f1..c285f539 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -382,7 +382,8 @@ "col_added": "Ajouté", "col_created_by": "Créé par", "col_opened": "Ouvert", - "col_path": "Emplacement" + "col_path": "Emplacement", + "new_elements": "Nouveaux éléments" }, "dialogs": { "rename_folder": "Renommer le dossier", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 12068545..e63f537e 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -382,7 +382,8 @@ "col_added": "जोड़ा गया", "col_created_by": "द्वारा बनाया गया", "col_opened": "खोला गया", - "col_path": "स्थान" + "col_path": "स्थान", + "new_elements": "नए तत्व" }, "dialogs": { "rename_folder": "फ़ोल्डर का नाम बदलें", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 888650c3..6a8a0335 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -382,7 +382,8 @@ "col_added": "Aggiunto", "col_created_by": "Creato da", "col_opened": "Aperto", - "col_path": "Posizione" + "col_path": "Posizione", + "new_elements": "Nuovi elementi" }, "dialogs": { "rename_folder": "Rinomina cartella", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index fcf883eb..adbb1125 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -382,7 +382,8 @@ "col_added": "追加日", "col_created_by": "作成者", "col_opened": "アクセス日時", - "col_path": "場所" + "col_path": "場所", + "new_elements": "新しいアイテム" }, "dialogs": { "rename_folder": "フォルダ名を変更", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 90b25a0f..05939621 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -471,6 +471,7 @@ "move_title": "\"{{name}}\" 이동", "moved": "이동됨", "new_folder_prompt": "새 폴더 이름", + "new_elements": "새 항목", "no_home": "홈 폴더를 사용할 수 없습니다.", "no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.", "no_subfolders": "하위 폴더가 없습니다.", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 6cea32c5..916950f7 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -382,7 +382,8 @@ "col_added": "Toegevoegd", "col_created_by": "Gemaakt door", "col_opened": "Geopend", - "col_path": "Locatie" + "col_path": "Locatie", + "new_elements": "Nieuwe items" }, "dialogs": { "rename_folder": "Map hernoemen", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index 1e8c93a4..d8fc732d 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -382,7 +382,8 @@ "col_added": "Dodano", "col_created_by": "Utworzone przez", "col_opened": "Otwarte", - "col_path": "Lokalizacja" + "col_path": "Lokalizacja", + "new_elements": "Nowe elementy" }, "dialogs": { "rename_folder": "Zmień nazwę folderu", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index ae8fb450..c67388a8 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -382,7 +382,8 @@ "col_added": "Adicionado", "col_created_by": "Criado por", "col_opened": "Aberto", - "col_path": "Localização" + "col_path": "Localização", + "new_elements": "Novos itens" }, "dialogs": { "rename_folder": "Renomear pasta", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 991e0ae3..ac182e02 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -382,7 +382,8 @@ "col_added": "Добавлено", "col_created_by": "Создано", "col_opened": "Открыт", - "col_path": "Расположение" + "col_path": "Расположение", + "new_elements": "Новые элементы" }, "dialogs": { "rename_folder": "Переименовать папку", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 5ba9fb19..6e0dc07e 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -382,7 +382,8 @@ "col_added": "新增日期", "col_created_by": "建立者", "col_opened": "開啟日期", - "col_path": "位置" + "col_path": "位置", + "new_elements": "新項目" }, "dialogs": { "rename_folder": "重新命名資料夾", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 21a9ab33..48f1dca7 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -382,7 +382,8 @@ "col_added": "添加日期", "col_created_by": "创建者", "col_opened": "打开日期", - "col_path": "位置" + "col_path": "位置", + "new_elements": "新元素" }, "dialogs": { "rename_folder": "重命名文件夹", From c286eed3b2b4dfda57e307f43d630859848be467 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 22:15:08 +0200 Subject: [PATCH 18/21] feat(ui): add a dropzone when uploading files from system - add a dropzone on the whole screen - correct z-index according design system --- frontend/src/lib/components/AppShell.svelte | 8 +- .../src/lib/components/ResourceList.svelte | 86 +++++++++++++++++-- frontend/static/locales/ar.json | 1 + frontend/static/locales/de.json | 1 + frontend/static/locales/en.json | 1 + frontend/static/locales/es.json | 1 + frontend/static/locales/fa.json | 1 + frontend/static/locales/fr.json | 1 + frontend/static/locales/hi.json | 1 + frontend/static/locales/it.json | 1 + frontend/static/locales/ja.json | 1 + frontend/static/locales/ko.json | 1 + frontend/static/locales/nl.json | 1 + frontend/static/locales/pl.json | 1 + frontend/static/locales/pt.json | 1 + frontend/static/locales/ru.json | 1 + frontend/static/locales/zh-TW.json | 1 + frontend/static/locales/zh.json | 1 + 18 files changed, 100 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 0abff3e4..fc2c335a 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -872,7 +872,10 @@ top: calc(100% + 4px); left: 0; right: 0; - z-index: 50; + /* Search suggestions render above `.page-sticky-header` — otherwise the + dropdown clips under the action bar on the content pages. Design-token + `--z-dropdown` (1000) sits above `--z-sticky` (100) by construction. */ + z-index: var(--z-dropdown); list-style: none; margin: 0; padding: 0.25rem; @@ -1117,7 +1120,8 @@ position: absolute; bottom: calc(100% + 4px); right: 0; - z-index: 60; + /* Sits above `--z-sticky` for the same reason as `.suggest` above. */ + z-index: var(--z-dropdown); min-width: 12rem; max-height: 18rem; overflow: auto; diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index ba645ee7..e7a003e0 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -902,28 +902,44 @@ // breadcrumb) are handled by the existing `onitemdrop` hooks and use // a private `application/x-oxi-item` MIME so the `Files` type check // below never matches them. - let systemDropOver = $state(false); + // Drag-enter/leave chatter is unavoidable when the drag pointer moves + // between the wrapper and its descendants — the browser fires + // `dragleave` on the parent BEFORE firing `dragenter` on the child, + // so a naive `systemDropOver = false` in the leave handler produces + // a false→true flash on every row hover during the drag. Counter + // approach: increment on every dragenter, decrement on every + // dragleave; the overlay is visible when the count is positive. The + // count zeroes only when the drag has truly left the wrapper (or + // hit `drop`/`dragend`), so the overlay stays stable throughout. + let systemDragDepth = $state(0); + const systemDropOver = $derived(systemDragDepth > 0); function isSystemDrag(e: DragEvent): boolean { return !!e.dataTransfer?.types?.includes('Files'); } function onSystemDragEnter(e: DragEvent) { if (!isSystemDrag(e)) return; e.preventDefault(); - systemDropOver = true; + systemDragDepth++; } function onSystemDragOver(e: DragEvent) { if (!isSystemDrag(e)) return; + // preventDefault on `dragover` is what tells the browser this + // element accepts drops — without it, `drop` never fires and + // the pointer shows the OS "no-drop" cursor. e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none'; } function onSystemDragLeave(e: DragEvent) { if (!isSystemDrag(e)) return; - systemDropOver = false; + if (systemDragDepth > 0) systemDragDepth--; } function onSystemDrop(e: DragEvent) { if (!isSystemDrag(e)) return; e.preventDefault(); - systemDropOver = false; + // Drop ends the drag; force-clear regardless of counter state + // (a stray unbalanced dragenter would otherwise leave the + // overlay stuck on). + systemDragDepth = 0; if (enableSystemDrop && onsystemdrop) { onsystemdrop(e); } else if (!enableSystemDrop) { @@ -1138,7 +1154,6 @@ {/if} + + + {#if systemDropOver && enableSystemDrop} + + {/if}
@@ -1419,13 +1452,50 @@ position: relative; } - .rl-root--drop-over::after { - content: ''; - position: absolute; + /* Viewport-fixed drop overlay. `position: fixed` (not absolute) so + it covers the whole visible browser window regardless of the + user's scroll position — an absolute-inset-0 inside `.rl-root` + would center the card at the middle of the FULL list height, + which sits above the fold on a scrolled folder. The dashed border + also gets painted at the true viewport edge, so the sticky + action-bar + breadcrumb are covered rather than clipping the + border. `pointer-events: none` so drag events still fall through + to `.rl-root`'s handlers underneath. + `--z-overlay` beats `--z-sticky` (page chrome) + `--z-dropdown` + (search suggestions); stays below `--z-modal` so a modal opened + concurrently still wins. */ + .rl-drop-overlay { + position: fixed; inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-accent) 12%, transparent); border: 2px dashed var(--color-accent); border-radius: var(--radius-md); pointer-events: none; + z-index: var(--z-overlay); + } + + .rl-drop-overlay__inner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-6) var(--space-8); + color: var(--color-accent); + background: var(--color-bg-surface); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + } + + .rl-drop-overlay :global(.rl-drop-overlay__icon) { + font-size: 3rem; + } + + .rl-drop-overlay__label { + font-weight: var(--weight-semibold); + font-size: var(--text-lg); } /* ── Rubberband (marquee) selection ──────────────────────────── diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index 00fcd333..5aec9991 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -339,6 +339,7 @@ "modified": "تاريخ التعديل", "no_files": "لا توجد ملفات في هذا المجلد", "empty_hint": "ارفع ملفات أو أنشئ مجلدات للبدء", + "drop_to_upload": "أفلت الملفات هنا للرفع", "loading": "جارٍ تحميل الملفات…", "view_grid": "عرض شبكي", "view_list": "عرض قائمة", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 5b3b6915..a0ec447d 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -339,6 +339,7 @@ "modified": "Geändert", "no_files": "Keine Dateien in diesem Ordner", "empty_hint": "Laden Sie Dateien hoch oder erstellen Sie Ordner, um loszulegen", + "drop_to_upload": "Dateien zum Hochladen hier ablegen", "loading": "Dateien werden geladen…", "view_grid": "Rasteransicht", "view_list": "Listenansicht", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 3438c366..628bde40 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -444,6 +444,7 @@ "modified": "Modified", "no_files": "No files in this folder", "empty_hint": "Upload files or create folders to get started", + "drop_to_upload": "Drop files here to upload", "loading": "Loading files…", "view_grid": "Grid view", "view_list": "List view", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index 5e959f44..19d0ddbd 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -339,6 +339,7 @@ "modified": "Modificado", "no_files": "No hay archivos en esta carpeta", "empty_hint": "Sube archivos o crea carpetas para comenzar", + "drop_to_upload": "Arrastra archivos aquí para subirlos", "loading": "Cargando archivos…", "view_grid": "Vista de cuadrícula", "view_list": "Vista de lista", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index 8a4f90d1..10db53d5 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -339,6 +339,7 @@ "modified": "تاریخ تغییر", "no_files": "هنوز هیچ پرونده‌ای در این پوشه وجود ندارد", "empty_hint": "برای شروع، فایل‌ها را آپلود کنید یا پوشه بسازید", + "drop_to_upload": "برای بارگذاری، فایل‌ها را اینجا رها کنید", "loading": "در حال بارگذاری فایل‌ها…", "view_grid": "نمای شبکه‌ای", "view_list": "نمای فهرستی", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index c285f539..b2cfc6c9 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -339,6 +339,7 @@ "modified": "Modifié", "no_files": "Aucun fichier dans ce dossier", "empty_hint": "Téléversez des fichiers ou créez des dossiers pour commencer", + "drop_to_upload": "Déposez les fichiers ici pour les téléverser", "loading": "Chargement des fichiers…", "view_grid": "Vue en grille", "view_list": "Vue en liste", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index e63f537e..31a152ed 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -339,6 +339,7 @@ "modified": "संशोधित", "no_files": "इस फ़ोल्डर में कोई फ़ाइल नहीं", "empty_hint": "शुरू करने के लिए ह़ैलें अपलोड करें या होल्डर बनाएँ", + "drop_to_upload": "अपलोड करने के लिए फ़ाइलें यहाँ छोड़ें", "loading": "फ़ाइलें लोड हो रही हैं…", "view_grid": "ग्रिड दृश्य", "view_list": "सूची दृश्य", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index 6a8a0335..1ddd3942 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -339,6 +339,7 @@ "modified": "Modificato", "no_files": "Nessun file in questa cartella", "empty_hint": "Carica file o crea cartelle per iniziare", + "drop_to_upload": "Trascina i file qui per caricarli", "loading": "Caricamento file…", "view_grid": "Visualizzazione griglia", "view_list": "Visualizzazione elenco", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index adbb1125..5feff156 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -339,6 +339,7 @@ "modified": "更新日", "no_files": "このフォルダにファイルはありません", "empty_hint": "ファイルをアップロードするかフォルダを作成して始めましょう", + "drop_to_upload": "アップロードするファイルをここにドロップ", "loading": "ファイルを読み込み中…", "view_grid": "グリッド表示", "view_list": "リスト表示", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 05939621..082c323b 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -409,6 +409,7 @@ "modified": "수정일", "no_files": "이 폴더에 파일이 없습니다", "empty_hint": "파일을 업로드하거나 폴더를 만들어 시작하세요", + "drop_to_upload": "업로드할 파일을 여기에 놓으세요", "loading": "파일 로딩 중…", "view_grid": "그리드 보기", "view_list": "목록 보기", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 916950f7..fc74afae 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -339,6 +339,7 @@ "modified": "Gewijzigd", "no_files": "Geen bestanden in deze map", "empty_hint": "Upload bestanden of maak mappen aan om te beginnen", + "drop_to_upload": "Sleep bestanden hier om te uploaden", "loading": "Bestanden laden…", "view_grid": "Rasterweergave", "view_list": "Lijstweergave", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index d8fc732d..f58b9426 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -339,6 +339,7 @@ "modified": "Zmodyfikowano", "no_files": "Brak plików w tym folderze", "empty_hint": "Prześlij pliki lub utwórz foldery, aby rozpocząć", + "drop_to_upload": "Upuść pliki tutaj, aby wysłać", "loading": "Ładowanie plików…", "view_grid": "Widok siatki", "view_list": "Widok listy", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index c67388a8..80dee965 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -339,6 +339,7 @@ "modified": "Modificado", "no_files": "Nenhum arquivo nesta pasta", "empty_hint": "Envie arquivos ou crie pastas para começar", + "drop_to_upload": "Solte arquivos aqui para enviar", "loading": "Carregando arquivos…", "view_grid": "Visualização em grade", "view_list": "Visualização em lista", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index ac182e02..71a83525 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -339,6 +339,7 @@ "modified": "Изменён", "no_files": "В этой папке нет файлов", "empty_hint": "Загрузите файлы или создайте папки, чтобы начать", + "drop_to_upload": "Перетащите файлы сюда для загрузки", "loading": "Загрузка файлов…", "view_grid": "Сетка", "view_list": "Список", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 6e0dc07e..c08b89ba 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -339,6 +339,7 @@ "modified": "修改日期", "no_files": "此資料夾中沒有檔案", "empty_hint": "上傳檔案或建立資料夾以開始使用", + "drop_to_upload": "將檔案拖放到此處上傳", "loading": "正在載入檔案…", "view_grid": "網格檢視", "view_list": "列表檢視", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 48f1dca7..61516be3 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -339,6 +339,7 @@ "modified": "修改日期", "no_files": "此文件夹中没有文件", "empty_hint": "上传文件或创建文件夹以开始使用", + "drop_to_upload": "将文件拖放到此处上传", "loading": "正在加载文件…", "view_grid": "网格视图", "view_list": "列表视图", From 0cc77f7a36249be0896a3a9ab7f4b875a8d8f9b2 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 20 Jul 2026 22:35:30 +0200 Subject: [PATCH 19/21] feat(ui): show a notification if user try to drop a file in another section than /files --- .../src/lib/components/ResourceList.svelte | 24 ++++++++++++++-- frontend/src/lib/components/Toaster.svelte | 28 +++++++++++++++++++ frontend/src/lib/stores/ui.svelte.ts | 24 ++++++++++++++-- frontend/static/locales/ar.json | 3 +- frontend/static/locales/de.json | 3 +- frontend/static/locales/en.json | 3 +- frontend/static/locales/es.json | 3 +- frontend/static/locales/fa.json | 3 +- frontend/static/locales/fr.json | 3 +- frontend/static/locales/hi.json | 3 +- frontend/static/locales/it.json | 3 +- frontend/static/locales/ja.json | 3 +- frontend/static/locales/ko.json | 3 +- frontend/static/locales/nl.json | 3 +- frontend/static/locales/pl.json | 3 +- frontend/static/locales/pt.json | 3 +- frontend/static/locales/ru.json | 3 +- frontend/static/locales/zh-TW.json | 3 +- frontend/static/locales/zh.json | 3 +- 19 files changed, 104 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index e7a003e0..3914150c 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -92,6 +92,8 @@ import DisplayModeControls from '$lib/components/DisplayModeControls.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import VirtualList from '$lib/components/VirtualList.svelte'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; import { t } from '$lib/i18n/index.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; @@ -927,7 +929,13 @@ // element accepts drops — without it, `drop` never fires and // the pointer shows the OS "no-drop" cursor. e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = enableSystemDrop ? 'copy' : 'none'; + // `dropEffect = 'none'` would tell the browser to REJECT the + // drop before `drop` fires — the toast/notification path in + // `onSystemDrop` would never run for wrong-zone drops. Always + // accept at the pointer level; the drop handler decides + // whether to upload (`enableSystemDrop`) or fire the + // "go to Files" toast. + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } function onSystemDragLeave(e: DragEvent) { if (!isSystemDrag(e)) return; @@ -949,7 +957,19 @@ 'Uploads only work in Files — open the Files section and drop there.' ), 'warning', - 6000 + 6000, + true, + { + action: { + label: t('resource_list.wrong_drop_zone_action', 'Go to Files'), + // One-click recovery from a mis-drop: land the user in + // /files so they can re-drag from the OS. We don't + // re-attach the dropped files (browsers throw away + // DataTransfer once the drop event returns), so this + // is the best we can offer without a second drag. + onClick: () => goto(resolve('/files')) + } + } ); } } diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte index ed13e130..72224f33 100644 --- a/frontend/src/lib/components/Toaster.svelte +++ b/frontend/src/lib/components/Toaster.svelte @@ -12,6 +12,18 @@ {#each ui.toasts as toast (toast.id)}
{toast.message} + {#if toast.action} + + {/if}