diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 14eb4024..d2288b6f 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -59,6 +59,7 @@ import { files as filesStore } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { gridColumns } from '$lib/utils/grid'; interface Props { title: string; @@ -148,6 +149,9 @@ const viewClass = $derived( filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view' ); + /** Content width, for computing the grid's column count to match auto-fill. */ + let gridWidth = $state(0); + const gridCols = $derived(gridColumns(gridWidth)); // Build the list-view column track from the enabled cells. const columns = $derived( @@ -409,45 +413,35 @@ hint={emptyHint} /> {:else} -
-
-
- {#if selectable} -
- -
- {/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 onfavorite || actions}
{/if} -
- - {#if grouped} +
+ {#if grouped} +
+ {@render listHeader()} {#each sections as section (section.key)}
{section.label}
{#each section.rows as entry (entry.id)} {@render row(entry)} {/each} {/each} - {:else if filesStore.viewMode === 'list'} - +
+ {:else if filesStore.viewMode === 'list'} + +
+ {@render listHeader()} e.id} {row} /> - {:else} - {#each items as entry (entry.id)} - {@render row(entry)} - {/each} - {/if} -
+
+ {:else} + + e.id} + {row} + /> + {/if} {#if hasMore}
{/if} +{#snippet listHeader()} +
+ {#if selectable} +
+ +
+ {/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 onfavorite || actions}
{/if} +
+{/snippet} + {#if ctxOpen && ctxEntry && contextActions}
1) the + * card height tracks the column width (e.g. an aspect-ratio thumbnail), so the + * pitch is the card height plus the grid's row gap, re-measured on resize. + */ function refineRowHeight(): void { - if (cols !== 1 || !rootEl) return; - const firstChild = rootEl.querySelector('.vlist__window > *') as HTMLElement | null; - if (!firstChild) return; - const h = firstChild.getBoundingClientRect().height; + const win = rootEl?.querySelector('.vlist__window') as HTMLElement | null; + const firstChild = win?.firstElementChild as HTMLElement | null; + if (!win || !firstChild) return; + let h = firstChild.getBoundingClientRect().height; + if (cols > 1) h += parseFloat(getComputedStyle(win).rowGap) || 0; if (h > 0 && Math.abs(h - measuredRow) > 0.5) measuredRow = h; } @@ -82,9 +87,12 @@ return stop; }); - // Refine the measured row height once rows are actually in the DOM. + // Re-measure the row pitch when rows first render, columns change, or a resize + // reflows the cards (grid card height depends on the column width). $effect(() => { void visible.length; + void cols; + void vw.resizeTick; refineRowHeight(); }); diff --git a/frontend/src/lib/composables/useVirtualWindow.svelte.ts b/frontend/src/lib/composables/useVirtualWindow.svelte.ts index 9f7db2d3..72f5a51c 100644 --- a/frontend/src/lib/composables/useVirtualWindow.svelte.ts +++ b/frontend/src/lib/composables/useVirtualWindow.svelte.ts @@ -14,10 +14,13 @@ export class VirtualWindow { aboveBy = $state(0); /** Height of the scrollable viewport in px. */ viewportH = $state(0); + /** Bumped on every resize so consumers can re-measure size-dependent layout. */ + resizeTick = $state(0); #root: HTMLElement | null = null; #scroller: HTMLElement | null = null; #ticking = false; + #resizing = false; /** Nearest scrollable ancestor, or null to mean the window/document. */ #findScroller(el: HTMLElement): HTMLElement | null { @@ -52,20 +55,30 @@ export class VirtualWindow { }); }; + #onResize = (): void => { + if (this.#resizing) return; + this.#resizing = true; + requestAnimationFrame(() => { + this.#resizing = false; + this.#measure(); + this.resizeTick++; + }); + }; + /** Begin observing `root`; returns a teardown to call from `onMount`. */ observe(root: HTMLElement): () => void { this.#root = root; this.#scroller = this.#findScroller(root); const target: EventTarget = this.#scroller ?? window; target.addEventListener('scroll', this.#onScroll, { passive: true }); - window.addEventListener('resize', this.#onScroll, { passive: true }); - const ro = new ResizeObserver(this.#onScroll); + window.addEventListener('resize', this.#onResize, { passive: true }); + const ro = new ResizeObserver(this.#onResize); if (this.#scroller) ro.observe(this.#scroller); ro.observe(root); this.#measure(); return () => { target.removeEventListener('scroll', this.#onScroll); - window.removeEventListener('resize', this.#onScroll); + window.removeEventListener('resize', this.#onResize); ro.disconnect(); }; } diff --git a/frontend/src/lib/utils/grid.ts b/frontend/src/lib/utils/grid.ts new file mode 100644 index 00000000..d2051f53 --- /dev/null +++ b/frontend/src/lib/utils/grid.ts @@ -0,0 +1,16 @@ +/** + * Number of columns a `.files-grid-view` (and the photos square grid share the + * idea) renders at a given container width. Mirrors the CSS + * `repeat(auto-fill, minmax(var(--grid-card-min), 1fr))` so a windowing list can + * compute row counts that match the browser's actual wrapping exactly. + * + * Card-min / gap track the tokens in `lib/styles/base/variables.css` and the + * ≤640px phone override in `lib/styles/ported/resourceList.css`. + */ +export function gridColumns(width: number): number { + if (width <= 0) return 1; + const mobile = typeof window !== 'undefined' && window.matchMedia('(max-width: 640px)').matches; + const cardMin = mobile ? 140 : 200; + const gap = mobile ? 8 : 20; + return Math.max(1, Math.floor((width + gap) / (cardMin + gap))); +} diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 5422b912..184459be 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -34,6 +34,7 @@ import type { FileItem, FolderItem, ItemType } from '$lib/api/types'; import FileViewer from '$lib/components/FileViewer.svelte'; import ListToolbar from '$lib/components/ListToolbar.svelte'; + import VirtualList from '$lib/components/VirtualList.svelte'; import MoveDialog from '$lib/components/MoveDialog.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import WopiEditor from '$lib/components/WopiEditor.svelte'; @@ -51,6 +52,7 @@ } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { gridColumns } from '$lib/utils/grid'; // The URL rest param is the trail of folder ids from home's children down. // /files → home root; /files/a/b → folder b inside a inside home. @@ -798,6 +800,16 @@ /** Flat id order matching how rows are displayed (folders then files). */ const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]); + // 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 @@ -1088,49 +1100,10 @@ hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')} /> {:else} -
-
-
-
- 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} -
-
- - {#if groupBy === ''} - {#each sortedFolders as folder (folder.id)} - {@render folderRow(folder)} - {/each} - {#each sortedFiles as file (file.id)} - {@render fileRow(file)} - {/each} - {:else} +
+ {#if groupBy !== ''} +
+ {@render fileListHeader()} {#each groups as group (group.key)}
{group.label}
{#each group.folders as folder (folder.id)} @@ -1140,12 +1113,71 @@ {@render fileRow(file)} {/each} {/each} - {/if} -
+
+ {: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)}