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/
|
node_modules/
|
||||||
|
/bench/
|
||||||
/build/
|
/build/
|
||||||
/.svelte-kit/
|
/.svelte-kit/
|
||||||
/package-lock.json.bak
|
/package-lock.json.bak
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
bench/
|
||||||
build/
|
build/
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
package/
|
package/
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ export default ts.config(
|
|||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: ts.parser
|
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
|
// `static/` holds vendored, verbatim assets (the delta-upload worker and
|
||||||
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
|
// 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>
|
</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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<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>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 {
|
export interface FolderListing {
|
||||||
folders: FolderItem[];
|
folders: FolderItem[];
|
||||||
files: FileItem[];
|
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. */
|
/** 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' });
|
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFolder(id: string): Promise<FolderItem> {
|
export async function getFolder(id: string): Promise<FolderItem> {
|
||||||
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
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);
|
* Fetch a folder listing, optionally conditionally. With `etag` set it sends
|
||||||
let url = `/api/folders/${folderId}/listing?t=${ts}`;
|
* `If-None-Match`; the server replies 304 (empty body) when nothing changed —
|
||||||
const headers: Record<string, string> = {
|
* the ETag covers folders + files + favorite/share badges — so the caller can
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate'
|
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of
|
||||||
};
|
* the way; revalidation is driven entirely by our own ETag.
|
||||||
if (forceRefresh) {
|
*/
|
||||||
url += '&force_refresh=true';
|
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';
|
headers['X-Force-Refresh'] = 'true';
|
||||||
}
|
}
|
||||||
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
|
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.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||||
const listing = (await res.json()) as Partial<FolderListing>;
|
|
||||||
return {
|
return {
|
||||||
folders: Array.isArray(listing.folders) ? listing.folders : [],
|
status: 200,
|
||||||
files: Array.isArray(listing.files) ? listing.files : []
|
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> {
|
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
|
||||||
const res = await apiFetch('/api/folders', {
|
const res = await apiFetch('/api/folders', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -54,10 +54,12 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SkeletonList from '$lib/components/SkeletonList.svelte';
|
import SkeletonList from '$lib/components/SkeletonList.svelte';
|
||||||
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
||||||
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||||
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -147,6 +149,9 @@
|
|||||||
const viewClass = $derived(
|
const viewClass = $derived(
|
||||||
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
|
||||||
);
|
);
|
||||||
|
/** Content width, for computing the grid's column count to match auto-fill. */
|
||||||
|
let gridWidth = $state(0);
|
||||||
|
const gridCols = $derived(gridColumns(gridWidth));
|
||||||
|
|
||||||
// Build the list-view column track from the enabled cells.
|
// Build the list-view column track from the enabled cells.
|
||||||
const columns = $derived(
|
const columns = $derived(
|
||||||
@@ -408,41 +413,35 @@
|
|||||||
hint={emptyHint}
|
hint={emptyHint}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||||
<div class={viewClass} style="--files-list-columns: {columns}">
|
{#if grouped}
|
||||||
<div class="list-header">
|
<div class={viewClass} style="--files-list-columns: {columns}">
|
||||||
{#if selectable}
|
{@render listHeader()}
|
||||||
<div class="select-cell">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={t('common.select_all', 'Select all')}
|
|
||||||
checked={allSelected}
|
|
||||||
onchange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div>{t('files.col_name', 'Name')}</div>
|
|
||||||
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
|
||||||
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
|
||||||
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
|
||||||
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
|
||||||
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
|
||||||
{#if onfavorite || actions}<div></div>{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if grouped}
|
|
||||||
{#each sections as section (section.key)}
|
{#each sections as section (section.key)}
|
||||||
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
|
||||||
{#each section.rows as entry (entry.id)}
|
{#each section.rows as entry (entry.id)}
|
||||||
{@render row(entry)}
|
{@render row(entry)}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
</div>
|
||||||
{#each items as entry (entry.id)}
|
{:else if filesStore.viewMode === 'list'}
|
||||||
{@render row(entry)}
|
<!-- Flat list view: only the visible rows are mounted. The spacer keeps the
|
||||||
{/each}
|
full scroll height so the end-of-list sentinel still fires. -->
|
||||||
{/if}
|
<div class="files-list-view" style="--files-list-columns: {columns}">
|
||||||
</div>
|
{@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}
|
{#if hasMore}
|
||||||
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
|
||||||
@@ -454,6 +453,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet listHeader()}
|
||||||
|
<div class="list-header">
|
||||||
|
{#if selectable}
|
||||||
|
<div class="select-cell">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={t('common.select_all', 'Select all')}
|
||||||
|
checked={allSelected}
|
||||||
|
onchange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>{t('files.col_name', 'Name')}</div>
|
||||||
|
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
|
||||||
|
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
|
||||||
|
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
|
||||||
|
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
|
||||||
|
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
|
||||||
|
{#if onfavorite || actions}<div></div>{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#if ctxOpen && ctxEntry && contextActions}
|
{#if ctxOpen && ctxEntry && contextActions}
|
||||||
<div
|
<div
|
||||||
class="rl-ctx-scrim"
|
class="rl-ctx-scrim"
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
item: Target | null;
|
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 tab = $state<'people' | 'link'>('people');
|
||||||
let directoryAvailable = $state(true);
|
let directoryAvailable = $state(true);
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
query = '';
|
query = '';
|
||||||
results = [];
|
results = [];
|
||||||
summarizeNotifications(res.notification.outcomes);
|
summarizeNotifications(res.notification.outcomes);
|
||||||
|
onshared?.(item.id);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
@@ -280,6 +283,7 @@
|
|||||||
newLinkName = '';
|
newLinkName = '';
|
||||||
password = '';
|
password = '';
|
||||||
expiresAt = null;
|
expiresAt = null;
|
||||||
|
onshared?.(item.id);
|
||||||
await loadShares();
|
await loadShares();
|
||||||
ui.notify(t('share.created', 'Public link created'), 'success');
|
ui.notify(t('share.created', 'Public link created'), 'success');
|
||||||
} catch (e) {
|
} 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 { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||||
import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte';
|
import {
|
||||||
|
getNestedValue,
|
||||||
|
interpolate,
|
||||||
|
resolveBrowserLocale,
|
||||||
|
initI18n,
|
||||||
|
t,
|
||||||
|
i18n
|
||||||
|
} from './index.svelte';
|
||||||
|
|
||||||
describe('resolveBrowserLocale', () => {
|
describe('resolveBrowserLocale', () => {
|
||||||
it('matches an exact full tag', () => {
|
it('matches an exact full tag', () => {
|
||||||
@@ -69,3 +76,44 @@ describe('interpolate', () => {
|
|||||||
expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items');
|
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;
|
store.locale = saved;
|
||||||
}
|
}
|
||||||
await loadDict(store.locale);
|
await loadDict(store.locale);
|
||||||
if (store.locale !== 'en') await loadDict('en');
|
|
||||||
applyHtmlLang(store.locale);
|
applyHtmlLang(store.locale);
|
||||||
store.loaded = true;
|
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> {
|
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);
|
let ready = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
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.
|
// Redirect old `#/...` bookmarks to the new path before anything else.
|
||||||
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
|
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
|
||||||
const mapped = hashUrlToPath(location.hash);
|
const mapped = hashUrlToPath(location.hash);
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import {
|
import {
|
||||||
|
cacheFolder,
|
||||||
createFolder,
|
createFolder,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
|
fetchFolderListing,
|
||||||
|
getCachedFolder,
|
||||||
getFolder,
|
getFolder,
|
||||||
listFolder,
|
getFolderName,
|
||||||
|
invalidateFolderCache,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
|
rememberFolderName,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
type FolderListing
|
type FolderListing
|
||||||
} from '$lib/api/endpoints/folders';
|
} from '$lib/api/endpoints/folders';
|
||||||
@@ -25,8 +30,7 @@
|
|||||||
} from '$lib/api/endpoints/files';
|
} from '$lib/api/endpoints/files';
|
||||||
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
||||||
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
||||||
import { addFavorite, fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||||
import { fetchMyShares } from '$lib/api/endpoints/grants';
|
|
||||||
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||||
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
@@ -34,6 +38,7 @@
|
|||||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||||
import FileViewer from '$lib/components/FileViewer.svelte';
|
import FileViewer from '$lib/components/FileViewer.svelte';
|
||||||
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
import ListToolbar from '$lib/components/ListToolbar.svelte';
|
||||||
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||||
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
import MoveDialog from '$lib/components/MoveDialog.svelte';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
||||||
@@ -51,12 +56,13 @@
|
|||||||
} from '$lib/stores/files.svelte';
|
} from '$lib/stores/files.svelte';
|
||||||
import { formatBytes } from '$lib/utils/format';
|
import { formatBytes } from '$lib/utils/format';
|
||||||
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
import { formatDate, iconNameFromClass } from '$lib/utils/display';
|
||||||
|
import { gridColumns } from '$lib/utils/grid';
|
||||||
|
|
||||||
// The URL rest param is the trail of folder ids from home's children down.
|
// The URL rest param is the trail of folder ids from home's children down.
|
||||||
// /files → home root; /files/a/b → folder b inside a inside home.
|
// /files → home root; /files/a/b → folder b inside a inside home.
|
||||||
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
|
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 crumbs = $state<Array<{ id: string; name: string }>>([]);
|
||||||
let currentId = $state<string | null>(null);
|
let currentId = $state<string | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -78,7 +84,9 @@
|
|||||||
let actionTarget = $state<ActionTarget | null>(null);
|
let actionTarget = $state<ActionTarget | null>(null);
|
||||||
let moveItems = $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 favoriteIds = $state<Set<string>>(new Set());
|
||||||
let sharedIds = $state<Set<string>>(new Set());
|
let sharedIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -99,23 +107,9 @@
|
|||||||
shareOpen = true;
|
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) {
|
async function toggleFavorite(kind: ItemType, id: string) {
|
||||||
const isFav = favoriteIds.has(id);
|
const isFav = favoriteIds.has(id);
|
||||||
// Optimistic toggle, reconcile on failure.
|
// Optimistic toggle, reverted on failure.
|
||||||
const next = new Set(favoriteIds);
|
const next = new Set(favoriteIds);
|
||||||
if (isFav) next.delete(id);
|
if (isFav) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
@@ -125,65 +119,116 @@
|
|||||||
else await addFavorite(kind, id);
|
else await addFavorite(kind, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(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 }>> {
|
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
|
||||||
// Names for each id in the trail; tolerate failures with a fallback label.
|
// Names come from the cache first (every listing names its children, so
|
||||||
const metas = await Promise.all(
|
// step-by-step navigation needs zero requests); only ids we've never seen
|
||||||
segments.map((id) =>
|
// — a cold deep-link's ancestors — are fetched, in parallel.
|
||||||
getFolder(id)
|
return Promise.all(
|
||||||
.then((f) => ({ id, name: f.name }))
|
segments.map(async (id) => {
|
||||||
.catch(() => ({ id, name: '…' }))
|
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() {
|
async function load() {
|
||||||
loading = true;
|
|
||||||
error = null;
|
error = null;
|
||||||
// Arm the delayed skeleton; cancel it the moment the load settles so fast
|
const seq = ++loadSeq;
|
||||||
// loads never flash placeholders (mirrors filesView.js' 100ms timer).
|
|
||||||
|
// 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(() => {
|
const skeletonTimer = setTimeout(() => {
|
||||||
if (loading) showSkeleton = true;
|
if (loading) showSkeleton = true;
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
||||||
|
// Breadcrumbs resolve independently so they never block the grid paint.
|
||||||
|
void buildCrumbs(pathSegments).then((trail) => {
|
||||||
|
if (seq === loadSeq) crumbs = trail;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// External users have no home folder; send them to shared-with-me.
|
const res = await fetchFolderListing(folderId, { etag: cached?.etag });
|
||||||
if (session.isExternalUser && pathSegments.length === 0) {
|
if (seq !== loadSeq) return; // superseded by a newer navigation
|
||||||
await goto('/shared-with-me', { replaceState: true });
|
if (res.status === 200 && res.listing) {
|
||||||
return;
|
applyListing(res.listing);
|
||||||
|
cacheFolder(folderId, res.listing, res.etag);
|
||||||
}
|
}
|
||||||
const home = await session.loadHomeFolder();
|
// 304 → the cached copy already on screen is current.
|
||||||
const folderId = pathSegments.at(-1) ?? home;
|
error = null;
|
||||||
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();
|
|
||||||
maybeOpenDeepLink();
|
maybeOpenDeepLink();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 403 → friendly message rather than the raw "Forbidden" error string.
|
if (seq !== loadSeq) return;
|
||||||
const status = (e as { status?: number })?.status;
|
// With a cached view already shown, keep it on a transient failure.
|
||||||
error =
|
if (!cached) {
|
||||||
status === 403
|
const status = (e as { status?: number })?.status;
|
||||||
? t('errors.forbidden', 'Could not load files')
|
error =
|
||||||
: e instanceof Error
|
status === 403
|
||||||
? e.message
|
? t('errors.forbidden', 'Could not load files')
|
||||||
: String(e);
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(skeletonTimer);
|
clearTimeout(skeletonTimer);
|
||||||
loading = false;
|
if (seq === loadSeq) {
|
||||||
showSkeleton = false;
|
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
|
* 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
|
* the freshly loaded listing, open it in the viewer (ported from
|
||||||
@@ -214,7 +259,7 @@
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
await createFolder(name, currentId);
|
await createFolder(name, currentId);
|
||||||
await load();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -260,7 +305,7 @@
|
|||||||
)
|
)
|
||||||
: t('files.uploaded', 'Upload complete');
|
: t('files.uploaded', 'Upload complete');
|
||||||
ui.finishProgress(nid, done, 'success');
|
ui.finishProgress(nid, done, 'success');
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -292,8 +337,11 @@
|
|||||||
if (!name || name === current) return;
|
if (!name || name === current) return;
|
||||||
try {
|
try {
|
||||||
if (kind === 'file') await renameFile(id, name);
|
if (kind === 'file') await renameFile(id, name);
|
||||||
else await renameFolder(id, name);
|
else {
|
||||||
await load();
|
await renameFolder(id, name);
|
||||||
|
rememberFolderName(id, name); // keep breadcrumbs current immediately
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -310,7 +358,7 @@
|
|||||||
try {
|
try {
|
||||||
if (kind === 'file') await deleteFile(id);
|
if (kind === 'file') await deleteFile(id);
|
||||||
else await deleteFolder(id);
|
else await deleteFolder(id);
|
||||||
await load();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -461,7 +509,6 @@
|
|||||||
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
||||||
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
void loadBadges();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -530,7 +577,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await load();
|
await reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
// ── Drag-to-move ─────────────────────────────────────────────────────────
|
||||||
@@ -569,7 +616,7 @@
|
|||||||
else await moveFolder(it.id, targetFolderId);
|
else await moveFolder(it.id, targetFolderId);
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorToast(err);
|
errorToast(err);
|
||||||
}
|
}
|
||||||
@@ -746,7 +793,7 @@
|
|||||||
await uploadFile(dirId, file);
|
await uploadFile(dirId, file);
|
||||||
}
|
}
|
||||||
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
|
||||||
await load();
|
await reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorToast(err);
|
errorToast(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -798,6 +845,16 @@
|
|||||||
/** Flat id order matching how rows are displayed (folders then files). */
|
/** Flat id order matching how rows are displayed (folders then files). */
|
||||||
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
|
const orderedIds = $derived([...sortedFolders.map((f) => f.id), ...sortedFiles.map((f) => f.id)]);
|
||||||
|
|
||||||
|
// Folders-then-files as one ordered, discriminated list so the (flat) view can
|
||||||
|
// be windowed by a single VirtualList. Content width drives the grid columns.
|
||||||
|
type Entry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
|
||||||
|
const entries = $derived<Entry[]>([
|
||||||
|
...sortedFolders.map((folder) => ({ kind: 'folder' as const, folder })),
|
||||||
|
...sortedFiles.map((file) => ({ kind: 'file' as const, file }))
|
||||||
|
]);
|
||||||
|
const entryKey = (e: Entry): string => (e.kind === 'folder' ? e.folder.id : e.file.id);
|
||||||
|
let gridWidth = $state(0);
|
||||||
|
|
||||||
// ── Group-by / swimlanes ─────────────────────────────────────────────────
|
// ── Group-by / swimlanes ─────────────────────────────────────────────────
|
||||||
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
|
// Mirrors GROUP_BY_DEFS in static/js/app/filesView.js: a flat list ('') plus
|
||||||
// Type / Size / Modified date / Created date dimensions. Folders always group
|
// Type / Size / Modified date / Created date dimensions. Folders always group
|
||||||
@@ -1088,49 +1145,10 @@
|
|||||||
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
hint={t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="files-container">
|
<div class="files-container" bind:clientWidth={gridWidth}>
|
||||||
<div class={viewClass}>
|
{#if groupBy !== ''}
|
||||||
<div class="list-header">
|
<div class={viewClass}>
|
||||||
<div class="list-header-checkbox">
|
{@render fileListHeader()}
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={t('files.select_all', 'Select all')}
|
|
||||||
checked={selectedCount > 0 && selectedCount === totalCount}
|
|
||||||
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
|
||||||
onchange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)}
|
|
||||||
{#if col.f === 'owner'}
|
|
||||||
<div class="list-header-owner">{col.l}</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="list-header-sort"
|
|
||||||
class:is-active={sortField === col.f}
|
|
||||||
data-sort-field={col.f}
|
|
||||||
onclick={() => toggleSort(col.f as SortField)}
|
|
||||||
>
|
|
||||||
{col.l}
|
|
||||||
{#if sortField === col.f}
|
|
||||||
<Icon
|
|
||||||
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
|
|
||||||
class="list-header-sort__arrow"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if groupBy === ''}
|
|
||||||
{#each sortedFolders as folder (folder.id)}
|
|
||||||
{@render folderRow(folder)}
|
|
||||||
{/each}
|
|
||||||
{#each sortedFiles as file (file.id)}
|
|
||||||
{@render fileRow(file)}
|
|
||||||
{/each}
|
|
||||||
{:else}
|
|
||||||
{#each groups as group (group.key)}
|
{#each groups as group (group.key)}
|
||||||
<div class="resource-list__swimlane-header">{group.label}</div>
|
<div class="resource-list__swimlane-header">{group.label}</div>
|
||||||
{#each group.folders as folder (folder.id)}
|
{#each group.folders as folder (folder.id)}
|
||||||
@@ -1140,12 +1158,71 @@
|
|||||||
{@render fileRow(file)}
|
{@render fileRow(file)}
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{:else if filesStore.viewMode === 'list'}
|
||||||
|
<!-- Flat list: only the rows near the viewport are mounted. -->
|
||||||
|
<div class="files-list-view">
|
||||||
|
{@render fileListHeader()}
|
||||||
|
<VirtualList items={entries} rowHeight={56} key={entryKey} row={entryRow} />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Grid: the windowed list's inner element IS the card grid. -->
|
||||||
|
<VirtualList
|
||||||
|
items={entries}
|
||||||
|
columns={gridColumns(gridWidth)}
|
||||||
|
rowHeight={240}
|
||||||
|
windowClass="files-grid-view"
|
||||||
|
key={entryKey}
|
||||||
|
row={entryRow}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#snippet fileListHeader()}
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-header-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={t('files.select_all', 'Select all')}
|
||||||
|
checked={selectedCount > 0 && selectedCount === totalCount}
|
||||||
|
indeterminate={selectedCount > 0 && selectedCount < totalCount}
|
||||||
|
onchange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#each [{ f: 'name', l: t('files.col_name', 'Name') }, { f: 'owner', l: t('files.col_owner', 'Owner') }, { f: 'type', l: t('files.col_type', 'Type') }, { f: 'size', l: t('files.col_size', 'Size') }, { f: 'modified_at', l: t('files.col_modified', 'Modified') }] as col (col.f)}
|
||||||
|
{#if col.f === 'owner'}
|
||||||
|
<div class="list-header-owner">{col.l}</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="list-header-sort"
|
||||||
|
class:is-active={sortField === col.f}
|
||||||
|
data-sort-field={col.f}
|
||||||
|
onclick={() => toggleSort(col.f as SortField)}
|
||||||
|
>
|
||||||
|
{col.l}
|
||||||
|
{#if sortField === col.f}
|
||||||
|
<Icon
|
||||||
|
name={sortDir === 1 ? 'arrow-down' : 'arrow-up'}
|
||||||
|
class="list-header-sort__arrow"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet entryRow(e: Entry)}
|
||||||
|
{#if e.kind === 'folder'}
|
||||||
|
{@render folderRow(e.folder)}
|
||||||
|
{:else}
|
||||||
|
{@render fileRow(e.file)}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet folderRow(folder: FolderItem)}
|
{#snippet folderRow(folder: FolderItem)}
|
||||||
<div
|
<div
|
||||||
class="file-item"
|
class="file-item"
|
||||||
@@ -1396,10 +1473,14 @@
|
|||||||
mode={moveMode}
|
mode={moveMode}
|
||||||
onmoved={() => {
|
onmoved={() => {
|
||||||
clearSelection();
|
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} />
|
<FileViewer bind:open={viewerOpen} file={viewerFile} />
|
||||||
<WopiEditor
|
<WopiEditor
|
||||||
bind:open={wopiOpen}
|
bind:open={wopiOpen}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import PeopleView from '$lib/components/PeopleView.svelte';
|
import PeopleView from '$lib/components/PeopleView.svelte';
|
||||||
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
|
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
|
||||||
import PlacesMap from '$lib/components/PlacesMap.svelte';
|
import PlacesMap from '$lib/components/PlacesMap.svelte';
|
||||||
|
import VirtualRows from '$lib/components/VirtualRows.svelte';
|
||||||
import { useSelection } from '$lib/composables/useSelection.svelte';
|
import { useSelection } from '$lib/composables/useSelection.svelte';
|
||||||
import { errorToast } from '$lib/utils/errors';
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -138,6 +139,60 @@
|
|||||||
return rows;
|
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() {
|
async function loadMore() {
|
||||||
if (loading || exhausted) return;
|
if (loading || exhausted) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -408,31 +463,23 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="photos-area">
|
<div class="photos-area">
|
||||||
<div class="photos-measure" bind:clientWidth={gridWidth}>
|
<div class="photos-measure" bind:clientWidth={gridWidth}>
|
||||||
{#each groups as group (group.key)}
|
{#if photoRows.length}
|
||||||
<h2 class="photos-group">
|
<VirtualRows rows={photoRows} overscan={1000}>
|
||||||
{group.label} <span class="photos-group__count">{group.photos.length}</span>
|
{#snippet row(r)}
|
||||||
</h2>
|
{#if r.kind === 'header'}
|
||||||
{#if layoutMode === 'justified' && gridWidth > 0}
|
<div class="photos-group" style:height="{r.height}px">
|
||||||
{#each justifiedRows(group.photos, gridWidth) as row, ri (group.key + '-' + ri)}
|
{r.label} <span class="photos-group__count">{r.count}</span>
|
||||||
<div class="photos-jrow" style:height="{row.height}px">
|
</div>
|
||||||
{#each row.tiles as cell (cell.file.id)}
|
{:else}
|
||||||
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
|
<div class="photos-strip" style:height="{r.height}px" style:gap="{r.gap}px">
|
||||||
{/each}
|
{#each r.tiles as cell (cell.file.id)}
|
||||||
</div>
|
{@render tile(cell.file, `width:${cell.w}px;height:${cell.h}px`)}
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
</div>
|
||||||
<ul class="photos">
|
{/if}
|
||||||
{#each group.photos as photo (photo.id)}
|
{/snippet}
|
||||||
<li
|
</VirtualRows>
|
||||||
class="photos__cell photos__cell--square"
|
{/if}
|
||||||
class:selected={selected.has(photo.id)}
|
|
||||||
>
|
|
||||||
{@render tile(photo)}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -566,8 +613,13 @@
|
|||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Date header — fixed height (set inline) so the virtualizer's offset table
|
||||||
|
matches the rendered layout exactly. */
|
||||||
.photos-group {
|
.photos-group {
|
||||||
margin: var(--space-4) 0 var(--space-2);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--color-text-heading);
|
color: var(--color-text-heading);
|
||||||
}
|
}
|
||||||
@@ -578,20 +630,11 @@
|
|||||||
font-weight: var(--weight-normal);
|
font-weight: var(--weight-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
.photos {
|
/* A horizontal strip of explicitly-sized tiles — one virtualized row, used by
|
||||||
list-style: none;
|
both the square and justified layouts (the bottom gap is baked into the
|
||||||
margin: 0;
|
row's declared height). */
|
||||||
padding: 0;
|
.photos-strip {
|
||||||
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 {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-tile {
|
.photo-tile {
|
||||||
@@ -601,16 +644,6 @@
|
|||||||
background: var(--color-bg-muted);
|
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 {
|
.photo-tile.selected {
|
||||||
outline: 3px solid var(--color-accent);
|
outline: 3px solid var(--color-accent);
|
||||||
outline-offset: -3px;
|
outline-offset: -3px;
|
||||||
|
|||||||
@@ -12,4 +12,11 @@ pub struct FolderListingDto {
|
|||||||
pub folders: Vec<FolderDto>,
|
pub folders: Vec<FolderDto>,
|
||||||
/// Files inside the requested folder
|
/// Files inside the requested folder
|
||||||
pub files: Vec<FileDto>,
|
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 {
|
pub fn new(repo: Arc<FavoritesPgRepository>) -> Self {
|
||||||
Self { repo }
|
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 {
|
impl FavoritesUseCase for FavoritesService {
|
||||||
|
|||||||
@@ -186,6 +186,10 @@ impl ShareBrowseService {
|
|||||||
Ok(FolderListingDto {
|
Ok(FolderListingDto {
|
||||||
folders: folders_res?,
|
folders: folders_res?,
|
||||||
files: files_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
|
/// Creates a stub instance for tests that need to construct services
|
||||||
/// without a real PostgreSQL pool. Connecting to the lazy pool will
|
/// without a real PostgreSQL pool. Connecting to the lazy pool will
|
||||||
/// fail at runtime — only safe in tests that exercise types, not actual
|
/// fail at runtime — only safe in tests that exercise types, not actual
|
||||||
|
|||||||
@@ -169,6 +169,8 @@ impl FolderHandler {
|
|||||||
fn compute_listing_etag(
|
fn compute_listing_etag(
|
||||||
folders: &[crate::application::dtos::folder_dto::FolderDto],
|
folders: &[crate::application::dtos::folder_dto::FolderDto],
|
||||||
files: &[crate::application::dtos::file_dto::FileDto],
|
files: &[crate::application::dtos::file_dto::FileDto],
|
||||||
|
favorite_ids: &[String],
|
||||||
|
shared_ids: &[String],
|
||||||
) -> String {
|
) -> String {
|
||||||
let max_mod = folders
|
let max_mod = folders
|
||||||
.iter()
|
.iter()
|
||||||
@@ -180,6 +182,10 @@ impl FolderHandler {
|
|||||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
max_mod.hash(&mut hasher);
|
max_mod.hash(&mut hasher);
|
||||||
count.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())
|
format!("\"{:x}\"", hasher.finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,7 +211,48 @@ impl FolderHandler {
|
|||||||
|
|
||||||
match (folders_result, files_result) {
|
match (folders_result, files_result) {
|
||||||
(Ok(folders), Ok(files)) => {
|
(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
|
// 304 Not Modified if the client already has this version
|
||||||
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
if let Some(inm) = headers.get(header::IF_NONE_MATCH)
|
||||||
@@ -219,7 +266,12 @@ impl FolderHandler {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.into_response();
|
.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();
|
let mut resp = (StatusCode::OK, Json(listing)).into_response();
|
||||||
resp.headers_mut()
|
resp.headers_mut()
|
||||||
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
.insert(header::ETAG, header::HeaderValue::from_str(&etag).unwrap());
|
||||||
|
|||||||
Reference in New Issue
Block a user