Merge pull request #492 from AtalayaLabs/claude/frontend-performance-analysis-52a9he

Optimize folder listing with caching, virtualization, and badge batching
This commit is contained in:
Dionisio Pozo
2026-06-19 18:47:43 +02:00
committed by GitHub
22 changed files with 1160 additions and 212 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/
/bench/
/build/
/.svelte-kit/
/package-lock.json.bak
+1
View File
@@ -1,3 +1,4 @@
bench/
build/
.svelte-kit/
package/
+6 -1
View File
@@ -24,11 +24,16 @@ export default ts.config(
parserOptions: {
parser: ts.parser
}
},
// TypeScript + svelte-check already resolve identifiers (including `<script
// generics>` type params, which core `no-undef` can't see). Defer to them.
rules: {
'no-undef': 'off'
}
},
{
// `static/` holds vendored, verbatim assets (the delta-upload worker and
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
ignores: ['build/', '.svelte-kit/', 'package/', 'static/']
ignores: ['build/', '.svelte-kit/', 'package/', 'static/', 'bench/']
}
);
+44
View File
@@ -23,9 +23,53 @@
}
})();
</script>
<!--
Instant boot splash. Paints on HTML parse (before the app bundle and its
CSS load), covering the otherwise-blank gap during JS download/parse for a
faster perceived first paint. The root layout removes `#app-splash` as soon
as it mounts. `light-dark()` + the early `color-scheme` rules match the
resolved theme so there's no colour flash when the app CSS arrives.
-->
<style>
html[data-color-scheme='dark'] {
color-scheme: dark;
}
html[data-color-scheme='light'] {
color-scheme: light;
}
#app-splash {
position: fixed;
inset: 0;
z-index: 9999;
display: grid;
place-items: center;
background: light-dark(#f5f7fa, #0f172a);
}
#app-splash__spinner {
width: 38px;
height: 38px;
border-radius: 50%;
border: 3px solid light-dark(#e2e8f0, #334155);
border-top-color: #ff5e3a;
animation: app-splash-spin 0.7s linear infinite;
}
@keyframes app-splash-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
#app-splash__spinner {
animation: none;
}
}
</style>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div id="app-splash" role="status" aria-label="Loading">
<div id="app-splash__spinner"></div>
</div>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
import { apiFetch, apiJson } from '$lib/api/client';
import type { FolderItem } from '$lib/api/types';
import {
fetchFolderListing,
getCachedFolder,
cacheFolder,
invalidateFolderCache,
getFolder,
getFolderName,
rememberFolderName,
type FolderListing
} from './folders';
type RawListing = {
folders?: unknown[];
files?: unknown[];
favorite_ids?: string[];
shared_ids?: string[];
};
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response {
return {
status: opts.status,
ok: opts.status >= 200 && opts.status < 300,
json: async () => opts.body ?? {},
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) }
} as unknown as Response;
}
const emptyListing = (): FolderListing => ({
folders: [],
files: [],
favoriteIds: [],
sharedIds: []
});
const initHeaders = (call: number): Record<string, string> =>
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
beforeEach(() => {
vi.clearAllMocks();
invalidateFolderCache();
});
describe('fetchFolderListing (conditional)', () => {
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => {
vi.mocked(apiFetch).mockResolvedValue(
fakeRes({
status: 200,
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] },
etag: '"v1"'
})
);
const r = await fetchFolderListing('f1');
expect(r.status).toBe(200);
expect(r.etag).toBe('"v1"');
expect(r.listing?.favoriteIds).toEqual(['a']);
expect(r.listing?.sharedIds).toEqual(['b']);
expect(initHeaders(0)['If-None-Match']).toBeUndefined();
// No cache-busting query param — the URL must be stable for revalidation.
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing');
});
it('sends If-None-Match and surfaces a 304 with no body', async () => {
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 }));
const r = await fetchFolderListing('f1', { etag: '"v1"' });
expect(r.status).toBe(304);
expect(r.listing).toBeUndefined();
expect(initHeaders(0)['If-None-Match']).toBe('"v1"');
});
it('throws a 403 carrying its status', async () => {
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 403 }));
await expect(fetchFolderListing('f1')).rejects.toMatchObject({ status: 403 });
});
});
describe('folder listing cache (LRU + invalidation)', () => {
it('stores and retrieves a listing + its ETag', () => {
cacheFolder('a', emptyListing(), '"1"');
expect(getCachedFolder('a')?.etag).toBe('"1"');
expect(getCachedFolder('missing')).toBeUndefined();
});
it('evicts the least-recently-used entry past the cap', () => {
for (let i = 0; i < 45; i++) cacheFolder(`f${i}`, emptyListing());
expect(getCachedFolder('f0')).toBeUndefined(); // evicted (cap is 40)
expect(getCachedFolder('f44')).toBeDefined();
});
it('a read bumps recency so the touched entry survives eviction', () => {
for (let i = 0; i < 40; i++) cacheFolder(`f${i}`, emptyListing());
getCachedFolder('f0'); // bump f0 to most-recent
cacheFolder('extra', emptyListing()); // forces one eviction
expect(getCachedFolder('f0')).toBeDefined();
expect(getCachedFolder('f1')).toBeUndefined(); // f1 was now the oldest
});
it('invalidates a single folder, or the whole cache', () => {
cacheFolder('a', emptyListing());
cacheFolder('b', emptyListing());
invalidateFolderCache('a');
expect(getCachedFolder('a')).toBeUndefined();
expect(getCachedFolder('b')).toBeDefined();
invalidateFolderCache();
expect(getCachedFolder('b')).toBeUndefined();
});
});
describe('folder name cache (breadcrumbs)', () => {
const folder = (id: string, name: string): FolderItem => ({ id, name }) as unknown as FolderItem;
it("learns its children's names from a cached listing", () => {
cacheFolder('nc-parent', {
folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')],
files: [],
favoriteIds: [],
sharedIds: []
});
expect(getFolderName('nc-a')).toBe('Alpha');
expect(getFolderName('nc-b')).toBe('Beta');
expect(getFolderName('nc-unknown')).toBeUndefined();
});
it('records the name fetched by getFolder', async () => {
vi.mocked(apiJson).mockResolvedValue(folder('gf-1', 'Reports') as never);
const f = await getFolder('gf-1');
expect(f.name).toBe('Reports');
expect(getFolderName('gf-1')).toBe('Reports');
});
it('rememberFolderName overwrites a stale name (e.g. after a rename)', () => {
rememberFolderName('rn-1', 'Old');
expect(getFolderName('rn-1')).toBe('Old');
rememberFolderName('rn-1', 'New');
expect(getFolderName('rn-1')).toBe('New');
});
});
+118 -13
View File
@@ -13,6 +13,94 @@ const NO_CACHE: RequestInit = {
export interface FolderListing {
folders: FolderItem[];
files: FileItem[];
/** Ids in this listing the caller has favorited (server-computed badge set). */
favoriteIds: string[];
/** Ids in this listing the caller has an outgoing share/grant on. */
sharedIds: string[];
}
/** Result of a (possibly conditional) listing fetch. */
export interface FolderListingResult {
/** 200 with a fresh `listing`, or 304 → the caller should keep its cache. */
status: number;
listing?: FolderListing;
etag?: string;
}
// ── In-memory listing cache (stale-while-revalidate) ─────────────────────────
// Lets the files view paint a previously-visited folder instantly on
// back/forward navigation, then revalidate with `If-None-Match` (304 = no body).
interface CachedFolder {
listing: FolderListing;
etag?: string;
}
const FOLDER_CACHE_MAX = 40;
const folderCache = new Map<string, CachedFolder>();
/** Cached listing for a folder, bumped to most-recently-used. */
export function getCachedFolder(folderId: string): CachedFolder | undefined {
const hit = folderCache.get(folderId);
if (hit) {
folderCache.delete(folderId);
folderCache.set(folderId, hit);
}
return hit;
}
export function cacheFolder(folderId: string, listing: FolderListing, etag?: string): void {
// Learn the children's names for breadcrumb resolution.
for (const f of listing.folders) rememberFolderName(f.id, f.name);
folderCache.delete(folderId);
folderCache.set(folderId, { listing, etag });
// Evict the least-recently-used entries past the cap.
while (folderCache.size > FOLDER_CACHE_MAX) {
const oldest = folderCache.keys().next().value;
if (oldest === undefined) break;
folderCache.delete(oldest);
}
}
/** Drop one folder, or the whole cache (no id), after a mutation. */
export function invalidateFolderCache(folderId?: string): void {
if (folderId === undefined) folderCache.clear();
else folderCache.delete(folderId);
}
// ── Folder name cache (breadcrumbs) ──────────────────────────────────────────
// id → name, learned from every listing (a folder's listing names its children)
// and from getFolder. Lets breadcrumbs resolve with zero requests during normal
// navigation (each ancestor was named by its parent's listing); only a cold
// deep-link fetches the names it hasn't seen.
const FOLDER_NAMES_MAX = 1000;
const folderNames = new Map<string, string>();
export function rememberFolderName(id: string, name: string): void {
folderNames.delete(id);
folderNames.set(id, name);
while (folderNames.size > FOLDER_NAMES_MAX) {
const oldest = folderNames.keys().next().value;
if (oldest === undefined) break;
folderNames.delete(oldest);
}
}
export function getFolderName(id: string): string | undefined {
return folderNames.get(id);
}
function parseListing(raw: unknown): FolderListing {
const o = (raw ?? {}) as {
folders?: FolderItem[];
files?: FileItem[];
favorite_ids?: string[];
shared_ids?: string[];
};
return {
folders: Array.isArray(o.folders) ? o.folders : [],
files: Array.isArray(o.files) ? o.files : [],
favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [],
sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : []
};
}
/** Top-level folders for the user; the first entry is the home folder. */
@@ -20,30 +108,47 @@ export function listRootFolders(): Promise<FolderItem[]> {
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
}
export function getFolder(id: string): Promise<FolderItem> {
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
export async function getFolder(id: string): Promise<FolderItem> {
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
rememberFolderName(folder.id, folder.name);
return folder;
}
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const ts = Math.floor(Date.now() / 1000);
let url = `/api/folders/${folderId}/listing?t=${ts}`;
const headers: Record<string, string> = {
'Cache-Control': 'no-cache, no-store, must-revalidate'
};
if (forceRefresh) {
url += '&force_refresh=true';
/**
* Fetch a folder listing, optionally conditionally. With `etag` set it sends
* `If-None-Match`; the server replies 304 (empty body) when nothing changed —
* the ETag covers folders + files + favorite/share badges — so the caller can
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of
* the way; revalidation is driven entirely by our own ETag.
*/
export async function fetchFolderListing(
folderId: string,
opts: { etag?: string; forceRefresh?: boolean } = {}
): Promise<FolderListingResult> {
const headers: Record<string, string> = {};
if (opts.etag) headers['If-None-Match'] = opts.etag;
let url = `/api/folders/${folderId}/listing`;
if (opts.forceRefresh) {
url += '?force_refresh=true';
headers['X-Force-Refresh'] = 'true';
}
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
if (res.status === 304) return { status: 304 };
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const listing = (await res.json()) as Partial<FolderListing>;
return {
folders: Array.isArray(listing.folders) ? listing.folders : [],
files: Array.isArray(listing.files) ? listing.files : []
status: 200,
listing: parseListing(await res.json()),
etag: res.headers.get('ETag') ?? undefined
};
}
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const res = await fetchFolderListing(folderId, { forceRefresh });
return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] };
}
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
const res = await apiFetch('/api/folders', {
method: 'POST',
+50 -29
View File
@@ -54,10 +54,12 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonList from '$lib/components/SkeletonList.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import VirtualList from '$lib/components/VirtualList.svelte';
import { t } from '$lib/i18n/index.svelte';
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;
@@ -147,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(
@@ -408,41 +413,35 @@
hint={emptyHint}
/>
{:else}
<div class="files-container">
<div class={viewClass} style="--files-list-columns: {columns}">
<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>
{#if grouped}
<div class="files-container" bind:clientWidth={gridWidth}>
{#if grouped}
<div class={viewClass} style="--files-list-columns: {columns}">
{@render listHeader()}
{#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
{#each section.rows as entry (entry.id)}
{@render row(entry)}
{/each}
{/each}
{:else}
{#each items as entry (entry.id)}
{@render row(entry)}
{/each}
{/if}
</div>
</div>
{:else if filesStore.viewMode === 'list'}
<!-- 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} />
</div>
{:else}
<!-- Grid view: the windowed list's inner element IS the card grid. -->
<VirtualList
{items}
columns={gridCols}
rowHeight={240}
windowClass="files-grid-view"
key={(e) => e.id}
{row}
/>
{/if}
{#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
@@ -454,6 +453,28 @@
</div>
{/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}
<div
class="rl-ctx-scrim"
@@ -43,9 +43,11 @@
interface Props {
open: boolean;
item: Target | null;
/** Fired with the item id when an outgoing share (grant or link) is created. */
onshared?: (id: string) => void;
}
let { open = $bindable(false), item }: Props = $props();
let { open = $bindable(false), item, onshared }: Props = $props();
let tab = $state<'people' | 'link'>('people');
let directoryAvailable = $state(true);
@@ -159,6 +161,7 @@
query = '';
results = [];
summarizeNotifications(res.notification.outcomes);
onshared?.(item.id);
await loadGrants();
} catch (e) {
errorToast(e);
@@ -280,6 +283,7 @@
newLinkName = '';
password = '';
expiresAt = null;
onshared?.(item.id);
await loadShares();
ui.notify(t('share.created', 'Public link created'), 'success');
} catch (e) {
@@ -0,0 +1,122 @@
<script lang="ts" module>
/**
* Generic windowing list. Renders only the rows intersecting the nearest
* scrollable ancestor's viewport (plus an overscan margin), reserving the full
* scroll height with a sized spacer so the scrollbar, sticky headers and any
* end-of-list sentinel keep behaving exactly as with a fully-rendered list.
*
* Scroll-ancestor based (not its own scroll box) so it drops into the existing
* `.content-area` layout without changing the single-scrollbar UX. Row height
* is auto-measured for the single-column case; pass `rowHeight` as the estimate
* (and for multi-column grids, where it must be the row pitch incl. gap).
*/
export interface VirtualListProps<T> {
items: T[];
/** Row pitch in px (height incl. row gap). Auto-refined when columns === 1. */
rowHeight?: number;
/** Items per row; > 1 lays the window out as a grid. */
columns?: number;
/** Extra rows rendered above and below the viewport. */
overscan?: number;
/** Class applied to the inner window (e.g. the grid container class). */
windowClass?: string;
/** Inline style applied to the inner window (e.g. grid-template-columns). */
windowStyle?: string;
/** Stable key per item (defaults to the absolute index). */
key?: (item: T, index: number) => string | number;
row: import('svelte').Snippet<[T, number]>;
}
</script>
<script lang="ts" generics="T">
import { onMount } from 'svelte';
import { useVirtualWindow } from '$lib/composables/useVirtualWindow.svelte';
let {
items,
rowHeight = 48,
columns = 1,
overscan = 6,
windowClass = '',
windowStyle = '',
key,
row
}: VirtualListProps<T> = $props();
let rootEl: HTMLDivElement;
/** Measured row pitch in px; 0 until known, then refined from a real row. */
let measuredRow = $state(0);
const vw = useVirtualWindow();
const cols = $derived(Math.max(1, columns));
const effRowH = $derived(measuredRow > 0 ? measuredRow : rowHeight);
const rowCount = $derived(Math.ceil(items.length / cols));
const totalHeight = $derived(rowCount * effRowH);
// Visible row band, derived from the shared scroll signals + the row pitch.
const rh = $derived(effRowH || rowHeight);
const firstRow = $derived(Math.max(0, Math.floor(vw.aboveBy / rh) - overscan));
const lastRow = $derived(
Math.min(rowCount, Math.ceil((vw.aboveBy + vw.viewportH) / rh) + overscan)
);
const startIndex = $derived(firstRow * cols);
const endIndex = $derived(Math.min(items.length, lastRow * cols));
const offsetY = $derived(firstRow * effRowH);
const visible = $derived(items.slice(startIndex, endIndex));
/**
* 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 {
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;
}
onMount(() => {
const stop = vw.observe(rootEl);
requestAnimationFrame(() => {
refineRowHeight();
vw.remeasure();
});
return stop;
});
// 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();
});
</script>
<div bind:this={rootEl} class="vlist" style:height="{totalHeight}px">
<div
class="vlist__window {windowClass}"
style="transform: translateY({offsetY}px); {windowStyle}"
>
{#each visible as item, i (key ? key(item, startIndex + i) : startIndex + i)}
{@render row(item, startIndex + i)}
{/each}
</div>
</div>
<style>
.vlist {
position: relative;
width: 100%;
}
.vlist__window {
position: absolute;
inset: 0 0 auto;
will-change: transform;
}
</style>
@@ -0,0 +1,119 @@
<script lang="ts" module>
/**
* Variable-height windowing list. Unlike {@link VirtualList} (uniform row
* pitch), each row declares its own `height`, so a single list can mix section
* headers and content rows of differing heights — e.g. the photo timeline's
* date headers and (square or justified) tile strips.
*
* It builds a prefix-sum offset table once per `rows` change and binary-searches
* the visible band on scroll, rendering only those rows (plus an overscan
* margin) and reserving the full height with a spacer so the scrollbar and any
* end-of-list sentinel behave exactly as with a fully-rendered list. Declared
* `height` MUST match the rendered row height or rows will drift.
*/
export interface VirtualRow {
/** Rendered height of this row in px (incl. its own bottom gap). */
height: number;
/** Stable identity; keeps unchanged rows mounted as the window slides. */
key?: string | number;
}
export interface VirtualRowsProps<T extends VirtualRow> {
rows: T[];
/** Extra pixels rendered above and below the viewport. */
overscan?: number;
windowClass?: string;
windowStyle?: string;
row: import('svelte').Snippet<[T, number]>;
}
</script>
<script lang="ts" generics="T extends VirtualRow">
import { onMount } from 'svelte';
import { useVirtualWindow } from '$lib/composables/useVirtualWindow.svelte';
let {
rows,
overscan = 600,
windowClass = '',
windowStyle = '',
row
}: VirtualRowsProps<T> = $props();
let rootEl: HTMLDivElement;
const vw = useVirtualWindow();
// offsets[i] = Y of row i; offsets[rows.length] = total height.
const offsets = $derived.by(() => {
const o = new Array<number>(rows.length + 1);
o[0] = 0;
for (let i = 0; i < rows.length; i++) o[i + 1] = o[i] + rows[i].height;
return o;
});
const totalHeight = $derived(offsets[rows.length] ?? 0);
/** First index whose offset is > x. */
function upperBound(arr: number[], x: number): number {
let lo = 0;
let hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] <= x) lo = mid + 1;
else hi = mid;
}
return lo;
}
/** First index whose offset is >= x. */
function lowerBound(arr: number[], x: number): number {
let lo = 0;
let hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] < x) lo = mid + 1;
else hi = mid;
}
return lo;
}
const band = $derived.by(() => {
const n = rows.length;
if (n === 0) return { first: 0, last: 0, top: 0 };
const topPx = vw.aboveBy - overscan;
const botPx = vw.aboveBy + vw.viewportH + overscan;
let first = upperBound(offsets, topPx) - 1; // row straddling/just above the top
if (first < 0) first = 0;
let last = lowerBound(offsets, botPx); // exclusive: first row starting at/after bottom
if (last < first + 1) last = first + 1;
if (last > n) last = n;
return { first, last, top: offsets[first] };
});
const visible = $derived(rows.slice(band.first, band.last));
onMount(() => vw.observe(rootEl));
</script>
<div bind:this={rootEl} class="vrows" style:height="{totalHeight}px">
<div
class="vrows__window {windowClass}"
style="transform: translateY({band.top}px); {windowStyle}"
>
{#each visible as r, i (r.key ?? band.first + i)}
{@render row(r, band.first + i)}
{/each}
</div>
</div>
<style>
.vrows {
position: relative;
width: 100%;
}
.vrows__window {
position: absolute;
inset: 0 0 auto;
will-change: transform;
}
</style>
@@ -0,0 +1,94 @@
/**
* Shared scroll-window tracker for windowing lists. Reactively reports how far a
* list element's top has scrolled above the nearest scrollable ancestor's
* viewport (`aboveBy`, px) and that viewport's height (`viewportH`).
*
* `VirtualList` (uniform rows) and `VirtualRows` (variable-height, section-aware
* rows) each derive their own visible slice from these two signals, so the
* scroll-ancestor detection and the rAF-throttled measurement live in exactly
* one place. Ancestor-based (not its own scroll box) so it drops into an
* existing scroll container without changing the single-scrollbar UX.
*/
export class VirtualWindow {
/** Pixels of the list scrolled above the viewport top (negative until reached). */
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 {
let node = el.parentElement;
while (node) {
const oy = getComputedStyle(node).overflowY;
if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return node;
node = node.parentElement;
}
return null;
}
#measure = (): void => {
const root = this.#root;
if (!root) return;
const rootTop = root.getBoundingClientRect().top;
if (this.#scroller) {
this.aboveBy = this.#scroller.getBoundingClientRect().top - rootTop;
this.viewportH = this.#scroller.clientHeight;
} else {
this.aboveBy = -rootTop;
this.viewportH = window.innerHeight;
}
};
#onScroll = (): void => {
if (this.#ticking) return;
this.#ticking = true;
requestAnimationFrame(() => {
this.#ticking = false;
this.#measure();
});
};
#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.#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.#onResize);
ro.disconnect();
};
}
/** Force a synchronous re-measure (e.g. just after rows first render). */
remeasure(): void {
this.#measure();
}
}
export function useVirtualWindow(): VirtualWindow {
return new VirtualWindow();
}
+50 -2
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import {
getNestedValue,
interpolate,
resolveBrowserLocale,
initI18n,
t,
i18n
} from './index.svelte';
describe('resolveBrowserLocale', () => {
it('matches an exact full tag', () => {
@@ -69,3 +76,44 @@ describe('interpolate', () => {
expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items');
});
});
describe('initI18n — lazy English fallback', () => {
let resolveEn: () => void;
beforeEach(() => {
localStorage.setItem('oxicloud-locale', 'es');
resolveEn = () => {};
globalThis.fetch = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/es.json')) {
return Promise.resolve(new Response(JSON.stringify({ greeting: 'Hola' }), { status: 200 }));
}
if (url.includes('/en.json')) {
// Deferred: only resolves when the test flips it, proving init didn't wait.
return new Promise<Response>((res) => {
resolveEn = () =>
res(new Response(JSON.stringify({ only_en: 'English only' }), { status: 200 }));
});
}
return Promise.resolve(new Response('{}', { status: 404 }));
}) as unknown as typeof fetch;
});
it('is ready after only the active locale and warms en in the background', async () => {
// Resolves even though the en fetch is still pending — it isn't awaited.
await initI18n();
expect(i18n.loaded).toBe(true);
expect(i18n.locale).toBe('es');
expect(t('greeting')).toBe('Hola');
const urls = vi.mocked(globalThis.fetch).mock.calls.map((c) => String(c[0]));
expect(urls.some((u) => u.includes('/es.json'))).toBe(true);
expect(urls.some((u) => u.includes('/en.json'))).toBe(true); // en was kicked off
// A key missing from es is unresolved until en arrives, then falls back.
expect(t('only_en')).toBe('only_en');
resolveEn();
await new Promise((r) => setTimeout(r, 0));
expect(t('only_en')).toBe('English only');
});
});
+5 -1
View File
@@ -207,9 +207,13 @@ export async function initI18n(): Promise<void> {
store.locale = saved;
}
await loadDict(store.locale);
if (store.locale !== 'en') await loadDict('en');
applyHtmlLang(store.locale);
store.loaded = true;
// Warm the English fallback in the background. `t()` only consults it for
// keys the active (complete) locale is missing — and most call sites already
// pass an inline English fallback — so it must not block first paint. When it
// arrives, `dicts.en` is reactive, so any key that fell through re-renders.
if (store.locale !== 'en') void loadDict('en');
}
export async function setLocale(locale: Locale): Promise<boolean> {
+16
View File
@@ -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)));
}
+5
View File
@@ -21,6 +21,11 @@
let ready = $state(false);
onMount(async () => {
// The instant HTML boot splash has done its job — the app is mounted, so
// the route (login renders immediately; protected routes show their own
// loading state) is already in the DOM behind it.
document.getElementById('app-splash')?.remove();
// Redirect old `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
const mapped = hashUrlToPath(location.hash);
+195 -114
View File
@@ -6,11 +6,16 @@
import { page } from '$app/state';
import Icon from '$lib/icons/Icon.svelte';
import {
cacheFolder,
createFolder,
deleteFolder,
fetchFolderListing,
getCachedFolder,
getFolder,
listFolder,
getFolderName,
invalidateFolderCache,
moveFolder,
rememberFolderName,
renameFolder,
type FolderListing
} from '$lib/api/endpoints/folders';
@@ -25,8 +30,7 @@
} from '$lib/api/endpoints/files';
import { folderZipUrl } from '$lib/api/endpoints/folders';
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
import { addFavorite, fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
import { fetchMyShares } from '$lib/api/endpoints/grants';
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
import { apiFetch } from '$lib/api/client';
@@ -34,6 +38,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,12 +56,13 @@
} 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.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
let listing = $state<FolderListing>({ folders: [], files: [] });
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
@@ -78,7 +84,9 @@
let actionTarget = $state<ActionTarget | null>(null);
let moveItems = $state<ActionTarget[] | null>(null);
// Favorite + shared badges for items in the current folder.
// Favorite + shared badge sets for the current folder, seeded directly from
// the listing response (server-computed, scoped to these items — no extra
// per-navigation fetch) and updated optimistically on mutation.
let favoriteIds = $state<Set<string>>(new Set());
let sharedIds = $state<Set<string>>(new Set());
@@ -99,23 +107,9 @@
shareOpen = true;
}
/** Load favorite + outgoing-share id sets so items can show badges. */
async function loadBadges() {
try {
const [favs, shares] = await Promise.all([
fetchFavoritesPage({ limit: 200 }).catch(() => null),
fetchMyShares({ limit: 200 }).catch(() => null)
]);
favoriteIds = new Set((favs?.items ?? []).map((f) => f.resource.id));
sharedIds = new Set((shares?.items ?? []).map((s) => s.resource.id));
} catch {
/* badges are best-effort */
}
}
async function toggleFavorite(kind: ItemType, id: string) {
const isFav = favoriteIds.has(id);
// Optimistic toggle, reconcile on failure.
// Optimistic toggle, reverted on failure.
const next = new Set(favoriteIds);
if (isFav) next.delete(id);
else next.add(id);
@@ -125,65 +119,116 @@
else await addFavorite(kind, id);
} catch (e) {
errorToast(e);
await loadBadges();
const reverted = new Set(favoriteIds);
if (isFav) reverted.add(id);
else reverted.delete(id);
favoriteIds = reverted;
}
}
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// Names for each id in the trail; tolerate failures with a fallback label.
const metas = await Promise.all(
segments.map((id) =>
getFolder(id)
.then((f) => ({ id, name: f.name }))
.catch(() => ({ id, name: '…' }))
)
// Names come from the cache first (every listing names its children, so
// step-by-step navigation needs zero requests); only ids we've never seen
// — a cold deep-link's ancestors — are fetched, in parallel.
return Promise.all(
segments.map(async (id) => {
const known = getFolderName(id);
if (known !== undefined) return { id, name: known };
try {
const f = await getFolder(id);
return { id, name: f.name };
} catch {
return { id, name: '…' };
}
})
);
return metas;
}
// Bumped on every load; a stale in-flight response checks this before it
// writes state, so a fast navigation can't be clobbered by an older fetch.
let loadSeq = 0;
function applyListing(data: FolderListing) {
listing = data;
favoriteIds = new Set(data.favoriteIds);
sharedIds = new Set(data.sharedIds);
}
async function load() {
loading = true;
error = null;
// Arm the delayed skeleton; cancel it the moment the load settles so fast
// loads never flash placeholders (mirrors filesView.js' 100ms timer).
const seq = ++loadSeq;
// External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) {
await goto('/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;
// 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 {
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.
void buildCrumbs(pathSegments).then((trail) => {
if (seq === loadSeq) crumbs = trail;
});
try {
// External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) {
await goto('/shared-with-me', { replaceState: true });
return;
const res = await fetchFolderListing(folderId, { etag: cached?.etag });
if (seq !== loadSeq) return; // superseded by a newer navigation
if (res.status === 200 && res.listing) {
applyListing(res.listing);
cacheFolder(folderId, res.listing, res.etag);
}
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;
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
listing = data;
crumbs = trail;
void loadBadges();
// 304 → the cached copy already on screen is current.
error = null;
maybeOpenDeepLink();
} catch (e) {
// 403 → friendly message rather than the raw "Forbidden" error string.
const status = (e as { status?: number })?.status;
error =
status === 403
? t('errors.forbidden', 'Could not load files')
: e instanceof Error
? e.message
: String(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);
}
} finally {
clearTimeout(skeletonTimer);
loading = false;
showSkeleton = false;
if (seq === loadSeq) {
loading = false;
showSkeleton = false;
}
}
}
/** Data changed — drop cached listings and reload the current folder fresh. */
async function reload() {
invalidateFolderCache();
await load();
}
/**
* Deep-link auto-open: when the URL carries `?file=<id>` and that file is in
* the freshly loaded listing, open it in the viewer (ported from
@@ -214,7 +259,7 @@
if (!name) return;
try {
await createFolder(name, currentId);
await load();
await reload();
} catch (e) {
errorToast(e);
}
@@ -260,7 +305,7 @@
)
: t('files.uploaded', 'Upload complete');
ui.finishProgress(nid, done, 'success');
await load();
await reload();
} catch (err) {
ui.finishProgress(nid, errorMessage(err), 'error');
} finally {
@@ -292,8 +337,11 @@
if (!name || name === current) return;
try {
if (kind === 'file') await renameFile(id, name);
else await renameFolder(id, name);
await load();
else {
await renameFolder(id, name);
rememberFolderName(id, name); // keep breadcrumbs current immediately
}
await reload();
} catch (e) {
errorToast(e);
}
@@ -310,7 +358,7 @@
try {
if (kind === 'file') await deleteFile(id);
else await deleteFolder(id);
await load();
await reload();
} catch (e) {
errorToast(e);
}
@@ -461,7 +509,6 @@
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
clearSelection();
void loadBadges();
} catch (e) {
errorToast(e);
}
@@ -530,7 +577,7 @@
}
}
clearSelection();
await load();
await reload();
}
// ── Drag-to-move ─────────────────────────────────────────────────────────
@@ -569,7 +616,7 @@
else await moveFolder(it.id, targetFolderId);
}
clearSelection();
await load();
await reload();
} catch (err) {
errorToast(err);
}
@@ -746,7 +793,7 @@
await uploadFile(dirId, file);
}
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
await load();
await reload();
} catch (err) {
errorToast(err);
} finally {
@@ -798,6 +845,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<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 ─────────────────────────────────────────────────
// 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 +1145,10 @@
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
/>
{:else}
<div class="files-container">
<div class={viewClass}>
<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>
{#if groupBy === ''}
{#each sortedFolders as folder (folder.id)}
{@render folderRow(folder)}
{/each}
{#each sortedFiles as file (file.id)}
{@render fileRow(file)}
{/each}
{:else}
<div class="files-container" bind:clientWidth={gridWidth}>
{#if groupBy !== ''}
<div class={viewClass}>
{@render fileListHeader()}
{#each groups as group (group.key)}
<div class="resource-list__swimlane-header">{group.label}</div>
{#each group.folders as folder (folder.id)}
@@ -1140,12 +1158,71 @@
{@render fileRow(file)}
{/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>
{/if}
</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)}
<div
class="file-item"
@@ -1396,10 +1473,14 @@
mode={moveMode}
onmoved={() => {
clearSelection();
void load();
void reload();
}}
/>
<ShareDialog bind:open={shareOpen} item={actionTarget} />
<ShareDialog
bind:open={shareOpen}
item={actionTarget}
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
/>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<WopiEditor
bind:open={wopiOpen}
+82 -49
View File
@@ -4,6 +4,7 @@
import PeopleView from '$lib/components/PeopleView.svelte';
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import PlacesMap from '$lib/components/PlacesMap.svelte';
import VirtualRows from '$lib/components/VirtualRows.svelte';
import { useSelection } from '$lib/composables/useSelection.svelte';
import { errorToast } from '$lib/utils/errors';
import { onMount } from 'svelte';
@@ -138,6 +139,60 @@
return rows;
}
// ── Virtualized row model ────────────────────────────────────────────────
// Flatten the groups into a single list of fixed-height rows (a date header
// or a strip of sized tiles), so VirtualRows can window the whole timeline —
// only the rows near the viewport are mounted, regardless of library size.
const SQUARE_GAP = 4; // .25rem, matches the old grid gap
const SQUARE_MIN = 144; // 9rem minmax floor
const JUSTIFIED_GAP = 8; // .photos-jrow margin-bottom
const HEADER_H = 44;
type PhotoRow =
| { kind: 'header'; key: string; height: number; label: string; count: number }
| { kind: 'tiles'; key: string; height: number; gap: number; tiles: JustifiedTile[] };
const photoRows = $derived.by<PhotoRow[]>(() => {
const W = gridWidth;
if (W <= 0) return [];
const rows: PhotoRow[] = [];
const cols = Math.max(1, Math.floor((W + SQUARE_GAP) / (SQUARE_MIN + SQUARE_GAP)));
const cell = (W - (cols - 1) * SQUARE_GAP) / cols;
for (const g of groups) {
rows.push({
kind: 'header',
key: `h:${g.key}`,
height: HEADER_H,
label: g.label,
count: g.photos.length
});
if (layoutMode === 'justified') {
const jrows = justifiedRows(g.photos, W);
for (let ri = 0; ri < jrows.length; ri++) {
rows.push({
kind: 'tiles',
key: `${g.key}:j${ri}`,
height: jrows[ri].height + JUSTIFIED_GAP,
gap: JUSTIFIED_GAP,
tiles: jrows[ri].tiles
});
}
} else {
for (let i = 0; i < g.photos.length; i += cols) {
const tiles = g.photos.slice(i, i + cols).map((file) => ({ file, w: cell, h: cell }));
rows.push({
kind: 'tiles',
key: `${g.key}:s${i}`,
height: cell + SQUARE_GAP,
gap: SQUARE_GAP,
tiles
});
}
}
}
return rows;
});
async function loadMore() {
if (loading || exhausted) return;
loading = true;
@@ -408,31 +463,23 @@
{:else}
<div class="photos-area">
<div class="photos-measure" bind:clientWidth={gridWidth}>
{#each groups as group (group.key)}
<h2 class="photos-group">
{group.label} <span class="photos-group__count">{group.photos.length}</span>
</h2>
{#if layoutMode === 'justified' && gridWidth > 0}
{#each justifiedRows(group.photos, gridWidth) as row, ri (group.key + '-' + ri)}
<div class="photos-jrow" style:height="{row.height}px">
{#each row.tiles as cell (cell.file.id)}
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
{/each}
</div>
{/each}
{:else}
<ul class="photos">
{#each group.photos as photo (photo.id)}
<li
class="photos__cell photos__cell--square"
class:selected={selected.has(photo.id)}
>
{@render tile(photo)}
</li>
{/each}
</ul>
{/if}
{/each}
{#if photoRows.length}
<VirtualRows rows={photoRows} overscan={1000}>
{#snippet row(r)}
{#if r.kind === 'header'}
<div class="photos-group" style:height="{r.height}px">
{r.label} <span class="photos-group__count">{r.count}</span>
</div>
{:else}
<div class="photos-strip" style:height="{r.height}px" style:gap="{r.gap}px">
{#each r.tiles as cell (cell.file.id)}
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
{/each}
</div>
{/if}
{/snippet}
</VirtualRows>
{/if}
</div>
</div>
{/if}
@@ -566,8 +613,13 @@
padding: 0 1rem;
}
/* Date header — fixed height (set inline) so the virtualizer's offset table
matches the rendered layout exactly. */
.photos-group {
margin: var(--space-4) 0 var(--space-2);
display: flex;
align-items: center;
gap: var(--space-2);
margin: 0;
font-size: 1rem;
color: var(--color-text-heading);
}
@@ -578,20 +630,11 @@
font-weight: var(--weight-normal);
}
.photos {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
/* Justified rows: a flex row of aspect-preserving tiles. */
.photos-jrow {
/* A horizontal strip of explicitly-sized tiles — one virtualized row, used by
both the square and justified layouts (the bottom gap is baked into the
row's declared height). */
.photos-strip {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.photo-tile {
@@ -601,16 +644,6 @@
background: var(--color-bg-muted);
}
.photos__cell--square,
.photos__cell--square .photo-tile {
aspect-ratio: 1;
height: 100%;
}
.photos__cell--square {
list-style: none;
}
.photo-tile.selected {
outline: 3px solid var(--color-accent);
outline-offset: -3px;
@@ -12,4 +12,11 @@ pub struct FolderListingDto {
pub folders: Vec<FolderDto>,
/// Files inside the requested folder
pub files: Vec<FileDto>,
/// Ids (folders + files in this listing) the caller has favorited. Lets the
/// client render star badges without a separate per-navigation favorites
/// fetch. Sorted for a stable response / ETag.
pub favorite_ids: Vec<String>,
/// Ids in this listing the caller has an outgoing share/grant on (incl.
/// public links). Sorted for a stable response / ETag.
pub shared_ids: Vec<String>,
}
@@ -27,6 +27,17 @@ impl FavoritesService {
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
Self { repo }
}
/// Subset of `(item_id, item_type)` pairs the user has favorited — used to
/// stamp star badges onto a folder listing in one batched query (no N+1, no
/// global page fetch).
pub async fn favorited_ids(
&self,
user_id: Uuid,
items: &[(&str, &str)],
) -> Result<HashSet<String>> {
self.repo.batch_check_favorites(user_id, items).await
}
}
impl FavoritesUseCase for FavoritesService {
@@ -186,6 +186,10 @@ impl ShareBrowseService {
Ok(FolderListingDto {
folders: folders_res?,
files: files_res?,
// Public-share browsing is an anonymous, read-only context — no
// per-caller favorite/share badges apply.
favorite_ids: Vec::new(),
shared_ids: Vec::new(),
})
}
}
@@ -105,6 +105,35 @@ impl PgAclEngine {
}
}
/// Subset of `resource_ids` the caller has shared — i.e. has any outgoing
/// role grant on (a `user`/`group` grant or a `token` grant, the latter
/// being a public link). One batched query, mirroring the membership the
/// `/grants/outgoing/resources` endpoint exposes; used to stamp "shared"
/// badges onto a folder listing without a per-navigation grants fetch.
pub async fn shared_resource_ids(
&self,
granted_by: Uuid,
resource_ids: &[Uuid],
) -> Result<HashSet<Uuid>, DomainError> {
if resource_ids.is_empty() {
return Ok(HashSet::new());
}
let rows: Vec<(Uuid,)> = sqlx::query_as(
r#"
SELECT DISTINCT resource_id
FROM storage.role_grants
WHERE granted_by = $1
AND resource_id = ANY($2)
"#,
)
.bind(granted_by)
.bind(resource_ids)
.fetch_all(self.pool.as_ref())
.await
.map_err(|e| DomainError::internal_error("PgAcl", format!("shared_resource_ids: {e}")))?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
/// Creates a stub instance for tests that need to construct services
/// without a real PostgreSQL pool. Connecting to the lazy pool will
/// fail at runtime — only safe in tests that exercise types, not actual
+54 -2
View File
@@ -169,6 +169,8 @@ impl FolderHandler {
fn compute_listing_etag(
folders: &[crate::application::dtos::folder_dto::FolderDto],
files: &[crate::application::dtos::file_dto::FileDto],
favorite_ids: &[String],
shared_ids: &[String],
) -> String {
let max_mod = folders
.iter()
@@ -180,6 +182,10 @@ impl FolderHandler {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
max_mod.hash(&mut hasher);
count.hash(&mut hasher);
// Badge state is part of the representation — fold it in (both slices are
// sorted, so the hash is stable) so a favorite/share change busts the ETag.
favorite_ids.hash(&mut hasher);
shared_ids.hash(&mut hasher);
format!("\"{:x}\"", hasher.finish())
}
@@ -205,7 +211,48 @@ impl FolderHandler {
match (folders_result, files_result) {
(Ok(folders), Ok(files)) => {
let etag = Self::compute_listing_etag(&folders, &files);
// Badge enrichment for this listing: which items the caller has
// favorited / shared. Two batched, index-backed queries (run
// concurrently) replace the client's old per-navigation global
// favorites + outgoing-shares fetches — correct (no 200-item
// ceiling) and scoped to just the items on screen.
let fav_pairs: Vec<(&str, &str)> = folders
.iter()
.map(|f| (f.id.as_str(), "folder"))
.chain(files.iter().map(|f| (f.id.as_str(), "file")))
.collect();
let resource_uuids: Vec<uuid::Uuid> = folders
.iter()
.map(|f| f.id.as_str())
.chain(files.iter().map(|f| f.id.as_str()))
.filter_map(|s| uuid::Uuid::parse_str(s).ok())
.collect();
let (favorited, shared) = tokio::join!(
async {
match &state.favorites_service {
Some(svc) => svc
.favorited_ids(auth_user.id, &fav_pairs)
.await
.unwrap_or_default(),
None => Default::default(),
}
},
state
.authorization
.shared_resource_ids(auth_user.id, &resource_uuids)
);
let mut favorite_ids: Vec<String> = favorited.into_iter().collect();
favorite_ids.sort();
let mut shared_ids: Vec<String> = shared
.unwrap_or_default()
.into_iter()
.map(|u| u.to_string())
.collect();
shared_ids.sort();
let etag = Self::compute_listing_etag(&folders, &files, &favorite_ids, &shared_ids);
// 304 Not Modified if the client already has this version
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
@@ -219,7 +266,12 @@ impl FolderHandler {
.unwrap()
.into_response();
}
let listing = FolderListingDto { folders, files };
let listing = FolderListingDto {
folders,
files,
favorite_ids,
shared_ids,
};
let mut resp = (StatusCode::OK, Json(listing)).into_response();
resp.headers_mut()
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());