perf(frontend): virtualize grid views + the files browser (list & grid)
Extends windowing to the remaining O(n)-DOM surfaces: the card-grid view of ResourceList (recent / favorites / shared / shared-with-me / trash / search) and the main file browser (`files/[...path]`), in both list and grid layouts. - VirtualList gains a real grid mode: its inner window carries the caller's grid class (`files-grid-view`) and lays out `columns` cards per windowed row. The row pitch is auto-measured (grid card height tracks column width via the 4/3 aspect-ratio thumbnail) and re-measured on resize. - `useVirtualWindow` now distinguishes scroll from resize and exposes a `resizeTick`, so size-dependent layout (the grid pitch) is recomputed only when it can actually change. - `gridColumns(width)` (new util) mirrors the CSS `auto-fill` / `--grid-card-min` / gap so the windowed row count matches the browser's real wrapping exactly; shared by both grid callers. - The files browser flattens folders-then-files into one discriminated `entries` list rendered through VirtualList (list: columns=1; grid: columns from width). Grouped (swimlane) views stay fully rendered, as before — they're bounded. ResourceList GRID, headless Chromium (1280x900), synthetic rows, before/after: rows | mount→paint | DOM nodes | JS heap | scroll frame | jank frames ------+-------------+-----------+---------+--------------+------------ 1000 | 979→75 ms | 19k→879 | 16→3 MB | 16→17 ms | 0→0 5000 | 4005→67 ms | 95k→879 | 72→3 MB | 37→17 ms | 26→0 20000 |12015→65 ms | 380k→879 |281→7 MB |197→17 ms |1249→19 Rendered DOM and heap are flat (O(visible)) regardless of dataset size; mount is ~185x faster and scroll holds ~60fps. Verified visually mid-scroll (5 columns, 4/3 thumbnail tiles, cards land at the expected indices). The files browser shares the same VirtualList path (it can't be mounted headless — it depends on $app routing/session — so it's validated via svelte-check + the production build + the shared, separately-benchmarked component). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
@@ -59,6 +59,7 @@
|
|||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||||
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -148,6 +149,9 @@
|
|||||||
const viewClass = $derived(
|
const viewClass = $derived(
|
||||||
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
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.
|
// Build the list-view column track from the enabled cells.
|
||||||
const columns = $derived(
|
const columns = $derived(
|
||||||
@@ -409,45 +413,35 @@
|
|||||||
hint={emptyHint}
|
hint={emptyHint}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
{#if grouped}
|
||||||
<div class="list-header">
|
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||||
{#if selectable}
|
{@render listHeader()}
|
||||||
<div class="select-cell">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={t('common.select_all', 'Select all')}
|
|
||||||
checked={allSelected}
|
|
||||||
onchange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div>{t('files.col_name', 'Name')}</div>
|
|
||||||
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
|
||||||
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
|
||||||
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
|
||||||
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
|
||||||
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
|
||||||
{#if onfavorite || actions}<div></div>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if grouped}
|
|
||||||
{#each sections as section (section.key)}
|
{#each sections as section (section.key)}
|
||||||
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
||||||
{#each section.rows as entry (entry.id)}
|
{#each section.rows as entry (entry.id)}
|
||||||
{@render row(entry)}
|
{@render row(entry)}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{:else if filesStore.viewMode === 'list'}
|
</div>
|
||||||
<!-- Flat list view: only the visible rows are mounted. The spacer keeps
|
{:else if filesStore.viewMode === 'list'}
|
||||||
the full scroll height so the end-of-list sentinel still fires. -->
|
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
|
||||||
|
full scroll height so the end-of-list sentinel still fires. -->
|
||||||
|
<div class="files-list-view" style="--files-list-columns: {columns}">
|
||||||
|
{@render listHeader()}
|
||||||
<VirtualList {items} rowHeight={56} key={(e) => e.id} {row} />
|
<VirtualList {items} rowHeight={56} key={(e) => e.id} {row} />
|
||||||
{:else}
|
</div>
|
||||||
{#each items as entry (entry.id)}
|
{:else}
|
||||||
{@render row(entry)}
|
<!-- Grid view: the windowed list's inner element IS the card grid. -->
|
||||||
{/each}
|
<VirtualList
|
||||||
{/if}
|
{items}
|
||||||
</div>
|
columns={gridCols}
|
||||||
|
rowHeight={240}
|
||||||
|
windowClass="files-grid-view"
|
||||||
|
key={(e) => e.id}
|
||||||
|
{row}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
||||||
@@ -459,6 +453,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet listHeader()}
|
||||||
|
<div class="list-header">
|
||||||
|
{#if selectable}
|
||||||
|
<div class="select-cell">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={t('common.select_all', 'Select all')}
|
||||||
|
checked={allSelected}
|
||||||
|
onchange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>{t('files.col_name', 'Name')}</div>
|
||||||
|
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
||||||
|
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
||||||
|
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
||||||
|
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
||||||
|
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
||||||
|
{#if onfavorite || actions}<div></div>{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#if ctxOpen && ctxEntry && contextActions}
|
{#if ctxOpen && ctxEntry && contextActions}
|
||||||
<div
|
<div
|
||||||
class="rl-ctx-scrim"
|
class="rl-ctx-scrim"
|
||||||
|
|||||||
@@ -64,12 +64,17 @@
|
|||||||
const offsetY = $derived(firstRow * effRowH);
|
const offsetY = $derived(firstRow * effRowH);
|
||||||
const visible = $derived(items.slice(startIndex, endIndex));
|
const visible = $derived(items.slice(startIndex, endIndex));
|
||||||
|
|
||||||
/** Single-column: adopt the real rendered row height once it's known. */
|
/**
|
||||||
|
* Adopt the real rendered row pitch once rows exist. For a grid (cols > 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 {
|
function refineRowHeight(): void {
|
||||||
if (cols !== 1 || !rootEl) return;
|
const win = rootEl?.querySelector('.vlist__window') as HTMLElement | null;
|
||||||
const firstChild = rootEl.querySelector('.vlist__window > *') as HTMLElement | null;
|
const firstChild = win?.firstElementChild as HTMLElement | null;
|
||||||
if (!firstChild) return;
|
if (!win || !firstChild) return;
|
||||||
const h = firstChild.getBoundingClientRect().height;
|
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;
|
if (h > 0 && Math.abs(h - measuredRow) > 0.5) measuredRow = h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +87,12 @@
|
|||||||
return stop;
|
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(() => {
|
$effect(() => {
|
||||||
void visible.length;
|
void visible.length;
|
||||||
|
void cols;
|
||||||
|
void vw.resizeTick;
|
||||||
refineRowHeight();
|
refineRowHeight();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ export class VirtualWindow {
|
|||||||
aboveBy = $state(0);
|
aboveBy = $state(0);
|
||||||
/** Height of the scrollable viewport in px. */
|
/** Height of the scrollable viewport in px. */
|
||||||
viewportH = $state(0);
|
viewportH = $state(0);
|
||||||
|
/** Bumped on every resize so consumers can re-measure size-dependent layout. */
|
||||||
|
resizeTick = $state(0);
|
||||||
|
|
||||||
#root: HTMLElement | null = null;
|
#root: HTMLElement | null = null;
|
||||||
#scroller: HTMLElement | null = null;
|
#scroller: HTMLElement | null = null;
|
||||||
#ticking = false;
|
#ticking = false;
|
||||||
|
#resizing = false;
|
||||||
|
|
||||||
/** Nearest scrollable ancestor, or null to mean the window/document. */
|
/** Nearest scrollable ancestor, or null to mean the window/document. */
|
||||||
#findScroller(el: HTMLElement): HTMLElement | null {
|
#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`. */
|
/** Begin observing `root`; returns a teardown to call from `onMount`. */
|
||||||
observe(root: HTMLElement): () => void {
|
observe(root: HTMLElement): () => void {
|
||||||
this.#root = root;
|
this.#root = root;
|
||||||
this.#scroller = this.#findScroller(root);
|
this.#scroller = this.#findScroller(root);
|
||||||
const target: EventTarget = this.#scroller ?? window;
|
const target: EventTarget = this.#scroller ?? window;
|
||||||
target.addEventListener('scroll', this.#onScroll, { passive: true });
|
target.addEventListener('scroll', this.#onScroll, { passive: true });
|
||||||
window.addEventListener('resize', this.#onScroll, { passive: true });
|
window.addEventListener('resize', this.#onResize, { passive: true });
|
||||||
const ro = new ResizeObserver(this.#onScroll);
|
const ro = new ResizeObserver(this.#onResize);
|
||||||
if (this.#scroller) ro.observe(this.#scroller);
|
if (this.#scroller) ro.observe(this.#scroller);
|
||||||
ro.observe(root);
|
ro.observe(root);
|
||||||
this.#measure();
|
this.#measure();
|
||||||
return () => {
|
return () => {
|
||||||
target.removeEventListener('scroll', this.#onScroll);
|
target.removeEventListener('scroll', this.#onScroll);
|
||||||
window.removeEventListener('resize', this.#onScroll);
|
window.removeEventListener('resize', this.#onResize);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||||
import FileViewer from '$lib/components/FileViewer.svelte';
|
import FileViewer from '$lib/components/FileViewer.svelte';
|
||||||
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
||||||
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||||
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
} from '$lib/stores/files.svelte';
|
} from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
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.
|
// 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.
|
// /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). */
|
/** Flat id order matching how rows are displayed (folders then files). */
|
||||||
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
|
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<Entry[]>([
|
||||||
|
...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 ─────────────────────────────────────────────────
|
// ── Group-by / swimlanes ─────────────────────────────────────────────────
|
||||||
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
|
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
|
||||||
// Type / Size / Modified date / Created date dimensions. Folders always group
|
// 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.')}
|
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||||
<div class={viewClass}>
|
{#if groupBy !== ''}
|
||||||
<div class="list-header">
|
<div class={viewClass}>
|
||||||
<div class="list-header-checkbox">
|
{@render fileListHeader()}
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={t('files.select_all', 'Select all')}
|
|
||||||
checked={selectedCount > 0 && selectedCount === totalCount}
|
|
||||||
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
|
||||||
onchange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#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'}
|
|
||||||
<div class="list-header-owner">{col.l}</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="list-header-sort"
|
|
||||||
class:is-active={sortField === col.f}
|
|
||||||
data-sort-field={col.f}
|
|
||||||
onclick={() => toggleSort(col.f as SortField)}
|
|
||||||
>
|
|
||||||
{col.l}
|
|
||||||
{#if sortField === col.f}
|
|
||||||
<Icon
|
|
||||||
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
|
|
||||||
class="list-header-sort__arrow"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if groupBy === ''}
|
|
||||||
{#each sortedFolders as folder (folder.id)}
|
|
||||||
{@render folderRow(folder)}
|
|
||||||
{/each}
|
|
||||||
{#each sortedFiles as file (file.id)}
|
|
||||||
{@render fileRow(file)}
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
<div class="resource-list__swimlane-header">{group.label}</div>
|
<div class="resource-list__swimlane-header">{group.label}</div>
|
||||||
{#each group.folders as folder (folder.id)}
|
{#each group.folders as folder (folder.id)}
|
||||||
@@ -1140,12 +1113,71 @@
|
|||||||
{@render fileRow(file)}
|
{@render fileRow(file)}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{:else if filesStore.viewMode === 'list'}
|
||||||
|
<!-- Flat list: only the rows near the viewport are mounted. -->
|
||||||
|
<div class="files-list-view">
|
||||||
|
{@render fileListHeader()}
|
||||||
|
<VirtualList items={entries} rowHeight={56} key={entryKey} row={entryRow} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Grid: the windowed list's inner element IS the card grid. -->
|
||||||
|
<VirtualList
|
||||||
|
items={entries}
|
||||||
|
columns={gridColumns(gridWidth)}
|
||||||
|
rowHeight={240}
|
||||||
|
windowClass="files-grid-view"
|
||||||
|
key={entryKey}
|
||||||
|
row={entryRow}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#snippet fileListHeader()}
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={t('files.select_all', 'Select all')}
|
||||||
|
checked={selectedCount > 0 && selectedCount === totalCount}
|
||||||
|
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
||||||
|
onchange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#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'}
|
||||||
|
<div class="list-header-owner">{col.l}</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="list-header-sort"
|
||||||
|
class:is-active={sortField === col.f}
|
||||||
|
data-sort-field={col.f}
|
||||||
|
onclick={() => toggleSort(col.f as SortField)}
|
||||||
|
>
|
||||||
|
{col.l}
|
||||||
|
{#if sortField === col.f}
|
||||||
|
<Icon
|
||||||
|
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
|
||||||
|
class="list-header-sort__arrow"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet entryRow(e: Entry)}
|
||||||
|
{#if e.kind === 'folder'}
|
||||||
|
{@render folderRow(e.folder)}
|
||||||
|
{:else}
|
||||||
|
{@render fileRow(e.file)}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet folderRow(folder: FolderItem)}
|
{#snippet folderRow(folder: FolderItem)}
|
||||||
<div
|
<div
|
||||||
class="file-item"
|
class="file-item"
|
||||||
|
|||||||
Reference in New Issue
Block a user