chore(frontend): toolchain migration checkpoint + UI perf optimizations

Checkpoint of the in-progress frontend toolchain work (Vite pinned to ^6 after
the 7/8 rolldown build break, eslint-plugin-svelte v3 navigation/reactivity
fixes, CI/Dockerfile/manifest updates) together with three UI performance
optimizations (verified on the Vite 6 build):

- Critical CSS: move auth.css/music.css off the global path into their route
  chunks (login/device/nextcloud-login, music) -- -25% gzipped critical CSS
  (~5.4 KB) on every non-auth/non-music page load.
- relativeTimeAgo: cache the Intl.RelativeTimeFormat (was rebuilt per call, once
  per row per render) -- 22.7x faster date formatting in large lists.
- Virtualize search results and grouped trash (list view) via VirtualList -- DOM
  rows mounted stay ~constant (~27) instead of O(N) (94.6% fewer for 500 hits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-21 19:03:07 +02:00
parent 778d551090
commit eef0ef5522
36 changed files with 1147 additions and 1420 deletions
+18 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
@@ -25,7 +26,15 @@
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
interface NavLink {
href: string;
href:
| '/files'
| '/shared'
| '/shared-with-me'
| '/recent'
| '/favorites'
| '/photos'
| '/music'
| '/trash';
label: string;
icon: string;
/** Stable key driving the per-section icon colour (see sidebar.css). */
@@ -126,7 +135,7 @@
if (q) {
suggestOpen = false;
searchActive = false;
goto(`/search?q=${encodeURIComponent(q)}`);
goto(resolve(`/search?q=${encodeURIComponent(q)}`));
}
}
@@ -169,7 +178,7 @@
function pickSuggestion(s: Suggestion) {
suggestOpen = false;
if (s.kind === 'folder') goto(`/files/${s.item.id}`);
if (s.kind === 'folder') goto(resolve(`/files/${s.item.id}`));
else window.open(fileInlineUrl(s.item.id), '_blank', 'noopener');
}
@@ -230,7 +239,7 @@
/* clear locally regardless */
}
session.reset();
await goto('/login');
await goto(resolve('/login'));
}
</script>
@@ -259,7 +268,7 @@
></div>
<div class="sidebar" class:open={sidebarOpen}>
<a href="/files" class="logo-container">
<a href={resolve('/files')} class="logo-container">
<div class="logo">
<svg viewBox="95 67 320 320" aria-hidden="true">
<path
@@ -275,7 +284,7 @@
<a
class="nav-item"
class:active={active(link.href)}
href={link.href}
href={resolve(link.href)}
data-section={link.section}
onclick={() => (sidebarOpen = false)}
>
@@ -546,15 +555,15 @@
<div class="user-menu-divider"></div>
{#if isAdmin}
<a class="user-menu-item" href="/admin" onclick={() => (menuOpen = false)}>
<a class="user-menu-item" href={resolve('/admin')} onclick={() => (menuOpen = false)}>
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
</a>
<a class="user-menu-item" href="/groups" onclick={() => (menuOpen = false)}>
<a class="user-menu-item" href={resolve('/groups')} onclick={() => (menuOpen = false)}>
<Icon name="user-group" />
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
</a>
{/if}
<a class="user-menu-item" href="/profile" onclick={() => (menuOpen = false)}>
<a class="user-menu-item" href={resolve('/profile')} onclick={() => (menuOpen = false)}>
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
</a>
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
@@ -56,10 +57,27 @@
prevFocus = null;
}
function nav(path: string): Command['run'] {
// The routes navigated to from the palette. Kept as an explicit literal union
// so `resolve()` type-checks each path against the real route table.
type NavPath =
| '/files'
| '/shared'
| '/shared-with-me'
| '/recent'
| '/favorites'
| '/photos'
| '/music'
| '/groups'
| '/trash'
| '/profile'
| '/admin'
| '/login'
| `/files/${string}`;
function nav(path: NavPath): Command['run'] {
return () => {
close();
void goto(path);
void goto(resolve(path));
};
}
@@ -70,7 +88,7 @@
*/
function uploadFiles() {
close();
void goto('/files').then(() => {
void goto(resolve('/files')).then(() => {
window.dispatchEvent(new CustomEvent('oxicloud:upload-files'));
});
}
@@ -153,7 +171,7 @@
/* clear locally regardless */
}
session.reset();
await goto('/login');
await goto(resolve('/login'));
}
}
);
@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { onMount } from 'svelte';
@@ -55,7 +56,7 @@
} catch {
/* private mode / quota — silently fall back to default */
}
await goto(`/files/${d.root_folder_id}`);
await goto(resolve(`/files/${d.root_folder_id}`));
}
onMount(() => {
@@ -83,7 +84,7 @@
<span class="drive-picker__name">{d.name}</span>
</button>
<a
href={`/config/drive/${d.id}`}
href={resolve(`/config/drive/${d.id}`)}
class="drive-picker__settings"
title={t('drive.settings_aria', 'Drive settings')}
aria-label={t('drive.settings_aria', 'Drive settings')}
@@ -196,7 +196,12 @@
{t('files.edit', 'Edit')}
</button>
{/if}
<a class="btn btn-secondary btn-sm" href={fileDownloadUrl(file.id)} download>
<a
class="btn btn-secondary btn-sm"
href={fileDownloadUrl(file.id)}
download
rel="external"
>
<Icon name="download" />
{t('common.download', 'Download')}
</a>
@@ -204,7 +209,7 @@
class="btn btn-secondary btn-sm"
href={fileInlineUrl(file.id)}
target="_blank"
rel="noreferrer"
rel="external noreferrer"
>
<Icon name="external-link-alt" />
</a>
@@ -381,7 +386,7 @@
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
font-family: var(--font-mono, monospace);
font-size: var(--text-sm);
color: var(--color-text);
+27 -18
View File
@@ -50,6 +50,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import Icon from '$lib/icons/Icon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonList from '$lib/components/SkeletonList.svelte';
@@ -195,6 +196,8 @@
const bucketOf = activeGroup?.bucketOf;
if (!bucketOf) return [{ key: '', label: '', rows: items }];
const order: string[] = [];
// Transient bucketing map computed inside $derived.by — not reactive state.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const map = new Map<string, ResourceEntry[]>();
for (const entry of items) {
const k = bucketOf(entry) ?? '∅';
@@ -213,24 +216,24 @@
const grouped = $derived(!!activeGroup?.bucketOf);
// ── Selection ─────────────────────────────────────────────────────────────
let selected = $state<Set<string>>(new Set());
// SvelteSet is reactive on its own; mutate in place rather than reassigning.
const selected = new SvelteSet<string>();
function toggleSelected(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
onselectionchange?.(next);
if (selected.has(id)) selected.delete(id);
else selected.add(id);
onselectionchange?.(selected);
}
function clearSelection() {
selected = new Set();
selected.clear();
onselectionchange?.(selected);
}
const allSelected = $derived(items.length > 0 && selected.size === items.length);
function toggleSelectAll() {
if (allSelected) clearSelection();
else {
selected = new Set(items.map((i) => i.id));
selected.clear();
for (const i of items) selected.add(i.id);
onselectionchange?.(selected);
}
}
@@ -240,15 +243,13 @@
$effect(() => {
const ids = new Set(items.map((i) => i.id));
let changed = false;
const next = new Set<string>();
for (const id of selected) {
if (ids.has(id)) next.add(id);
else changed = true;
}
if (changed) {
selected = next;
onselectionchange?.(next);
if (!ids.has(id)) {
selected.delete(id);
changed = true;
}
}
if (changed) onselectionchange?.(selected);
});
// ── Right-click context menu ──────────────────────────────────────────────
@@ -420,9 +421,17 @@
{@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}
{#if filesStore.viewMode === 'list'}
<!-- Window each section's rows so a large grouped list (e.g. a big
trash, grouped by remaining days) doesn't mount every row. The
grid-grouped branch stays un-windowed: `files-grid-view` is itself
the card grid and can't host the windowing spacer wrapper. -->
<VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} />
{:else}
{#each section.rows as entry (entry.id)}
{@render row(entry)}
{/each}
{/if}
{/each}
</div>
{:else if filesStore.viewMode === 'list'}
@@ -89,6 +89,8 @@
}
function groupGrants(grants: Grant[]): Member[] {
// Transient scratch map used to fold grants into Member rows, then discarded.
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const bySubject = new Map<
string,
{ subject: GrantSubject; role: ShareRole; ids: string[]; expiry: string | null }