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:
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
/bench/
|
||||
/build/
|
||||
/.svelte-kit/
|
||||
/package-lock.json.bak
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
bench/
|
||||
build/
|
||||
.svelte-kit/
|
||||
package/
|
||||
|
||||
@@ -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/']
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user