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:
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { Pathname } from '$app/types';
|
||||
import { page, updated } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import '$lib/styles/app.css';
|
||||
@@ -40,8 +42,11 @@
|
||||
|
||||
// Redirect old `#/...` bookmarks to the new path before anything else.
|
||||
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
|
||||
// hashUrlToPath returns a dynamic in-app path string; resolve() is typed
|
||||
// for known route ids, so assert it as a Pathname (same precedent as the
|
||||
// post-login redirect target).
|
||||
const mapped = hashUrlToPath(location.hash);
|
||||
if (mapped) await goto(mapped, { replaceState: true });
|
||||
if (mapped) await goto(resolve(mapped as Pathname), { replaceState: true });
|
||||
}
|
||||
await session.load();
|
||||
ready = true;
|
||||
@@ -53,7 +58,7 @@
|
||||
if (!ready) return;
|
||||
const path = page.url.pathname;
|
||||
if (!session.isAuthenticated && !isPublic(path)) {
|
||||
void goto(`/login?redirect=${encodeURIComponent(path)}`, { replaceState: true });
|
||||
void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
@@ -12,7 +13,7 @@
|
||||
// `default_for_user` matches the caller).
|
||||
onMount(() => {
|
||||
const target = session.isExternalUser ? '/shared-with-me' : '/files';
|
||||
void goto(target, { replaceState: true });
|
||||
void goto(resolve(target), { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2134,7 +2134,7 @@
|
||||
}
|
||||
|
||||
.log-msg {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.logs-pager {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -70,7 +71,7 @@
|
||||
<p class="muted">
|
||||
{t('drive.not_found_body', "This drive doesn't exist or you don't have access to it.")}
|
||||
</p>
|
||||
<a class="link" href="/files">{t('drive.back_to_files', 'Back to Files')}</a>
|
||||
<a class="link" href={resolve('/files')}>{t('drive.back_to_files', 'Back to Files')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<h1>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
// Route-scoped auth styles (this page uses the .auth-* classes).
|
||||
import '$lib/styles/ported/auth.css';
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
dateBucket,
|
||||
@@ -132,7 +133,7 @@
|
||||
|
||||
function open(entry: ResourceEntry) {
|
||||
if (entry.kind === 'folder') {
|
||||
goto(`/files/${entry.id}`);
|
||||
goto(resolve(`/files/${entry.id}`));
|
||||
return;
|
||||
}
|
||||
const item = byId.get(entry.id);
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
import {
|
||||
cacheFolder,
|
||||
@@ -131,7 +133,7 @@
|
||||
async function toggleFavorite(kind: ItemType, id: string) {
|
||||
const isFav = favoriteIds.has(id);
|
||||
// Optimistic toggle, reverted on failure.
|
||||
const next = new Set(favoriteIds);
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(id);
|
||||
else next.add(id);
|
||||
favoriteIds = next;
|
||||
@@ -140,7 +142,7 @@
|
||||
else await addFavorite(kind, id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
const reverted = new Set(favoriteIds);
|
||||
const reverted = new SvelteSet(favoriteIds);
|
||||
if (isFav) reverted.add(id);
|
||||
else reverted.delete(id);
|
||||
favoriteIds = reverted;
|
||||
@@ -181,7 +183,7 @@
|
||||
|
||||
// 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 });
|
||||
await goto(resolve('/shared-with-me'), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
const home = await session.loadHomeFolder();
|
||||
@@ -195,7 +197,7 @@
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
||||
const target = last ?? home;
|
||||
if (target) {
|
||||
await goto(`/files/${target}`, { replaceState: true });
|
||||
await goto(resolve(`/files/${target}`), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -267,11 +269,7 @@
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
||||
}
|
||||
|
||||
function crumbHref(index: number): string {
|
||||
return `/files/${pathSegments.slice(0, index + 1).join('/')}`;
|
||||
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
|
||||
}
|
||||
|
||||
async function onNewFolder() {
|
||||
@@ -674,6 +672,9 @@
|
||||
// reflects the param into viewerOpen/viewerFile.
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('file', file.id);
|
||||
// Same-origin URL object built from page.url (already resolved); resolve()
|
||||
// only accepts a route string, so it can't type a dynamic URL instance.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
void goto(url, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
|
||||
@@ -705,6 +706,8 @@
|
||||
if (!viewerOpen && hasParam) {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('file');
|
||||
// Same-origin URL object (see note above); resolve() can't type it.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
||||
}
|
||||
});
|
||||
@@ -728,7 +731,7 @@
|
||||
let selectionAnchor = $state<string | null>(null);
|
||||
|
||||
function toggleSelected(id: string) {
|
||||
const next = new Set(selected);
|
||||
const next = new SvelteSet(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
selected = next;
|
||||
@@ -965,6 +968,9 @@
|
||||
// (DownloadURL can only point at a GET URL); file_ids/folder_ids are CSV.
|
||||
const fileIds = items.filter((i) => i.kind === 'file').map((i) => i.id);
|
||||
const folderIds = items.filter((i) => i.kind === 'folder').map((i) => i.id);
|
||||
// Transient query-string builder for a one-off download URL — not reactive
|
||||
// state, so a plain URLSearchParams is correct here.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const params = new URLSearchParams();
|
||||
if (fileIds.length) params.set('file_ids', fileIds.join(','));
|
||||
if (folderIds.length) params.set('folder_ids', folderIds.join(','));
|
||||
@@ -1163,7 +1169,7 @@
|
||||
// The current view already lists files inside their folder; navigate to the
|
||||
// file's own folder id (handles deep-link / search contexts where the file's
|
||||
// folder differs from the current path).
|
||||
goto(`/files/${file.folder_id}`);
|
||||
goto(resolve(`/files/${file.folder_id}`));
|
||||
}
|
||||
|
||||
// ── Download a folder as a zip archive ────────────────────────────────────
|
||||
@@ -1209,6 +1215,8 @@
|
||||
}
|
||||
|
||||
// Map each relative directory path to its created folder id; '' = current.
|
||||
// Local computation scratch map (discarded after upload) — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||
|
||||
async function ensureDir(relDir: string): Promise<string | null> {
|
||||
@@ -1342,6 +1350,10 @@
|
||||
// within each lane. Lanes appear in first-seen order (folders precede files).
|
||||
const groups = $derived.by<ResourceGroup[]>(() => {
|
||||
if (groupBy === '') return [];
|
||||
// Transient grouping map, local to this derivation and discarded once the
|
||||
// array is built — must stay a plain Map (a reactive one created inside a
|
||||
// $derived would be unsafe state).
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<string, ResourceGroup>();
|
||||
const ensure = (key: string): ResourceGroup => {
|
||||
let g = map.get(key);
|
||||
@@ -1575,7 +1587,7 @@
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href={crumbHref(i)}
|
||||
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
|
||||
class="breadcrumb-item breadcrumb-link"
|
||||
class:breadcrumb-home={i === 0}
|
||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||
@@ -1891,6 +1903,7 @@
|
||||
<a
|
||||
class="btn-action"
|
||||
href={fileDownloadUrl(file.id)}
|
||||
rel="external"
|
||||
download
|
||||
title={t('common.download', 'Download')}
|
||||
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
||||
@@ -1936,7 +1949,7 @@
|
||||
<ShareDialog
|
||||
bind:open={shareOpen}
|
||||
item={actionTarget}
|
||||
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
|
||||
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
|
||||
/>
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
@@ -1967,7 +1980,7 @@
|
||||
onclick={() => {
|
||||
const id = ctxTarget!.id;
|
||||
closeContext();
|
||||
goto(`/files/${[...pathSegments, id].join('/')}`);
|
||||
goto(resolve(`/files/${[...pathSegments, id].join('/')}`));
|
||||
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
||||
>
|
||||
<button
|
||||
@@ -2013,6 +2026,7 @@
|
||||
class="ctx-item"
|
||||
role="menuitem"
|
||||
href={fileDownloadUrl(ctxTarget.id)}
|
||||
rel="external"
|
||||
download
|
||||
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
||||
>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script lang="ts">
|
||||
// Route-scoped styles: kept off the global critical path (Vite code-splits
|
||||
// this into the /login route chunk, loaded only when this page renders).
|
||||
import '$lib/styles/ported/auth.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import type { Pathname } from '$app/types';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
exchangeOidcCode,
|
||||
@@ -65,7 +70,10 @@
|
||||
let oidc = $state<OidcProviders>({ enabled: false });
|
||||
const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false);
|
||||
|
||||
const redirectTarget = $derived(page.url.searchParams.get('redirect') || '/files');
|
||||
// The redirect target is an in-SPA destination (e.g. /files or a deep link a
|
||||
// guard bounced us from). It's user-supplied via the query string so its exact
|
||||
// value isn't a known route literal — cast to Pathname for resolve().
|
||||
const redirectTarget = $derived((page.url.searchParams.get('redirect') || '/files') as Pathname);
|
||||
const matchState = $derived(
|
||||
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
|
||||
);
|
||||
@@ -100,7 +108,7 @@
|
||||
return;
|
||||
}
|
||||
session.user = data.user;
|
||||
await goto(redirectTarget, { replaceState: true });
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
|
||||
} finally {
|
||||
@@ -196,7 +204,7 @@
|
||||
const user = await exchangeOidcCode(oidcCode);
|
||||
if (user) {
|
||||
session.user = user;
|
||||
await goto(redirectTarget, { replaceState: true });
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
// Exchange failed — fall through to the normal login UI.
|
||||
@@ -207,7 +215,7 @@
|
||||
const me = await fetchMe();
|
||||
if (me) {
|
||||
session.user = me;
|
||||
await goto(redirectTarget, { replaceState: true });
|
||||
await goto(resolve(redirectTarget), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -362,7 +370,8 @@
|
||||
{#if passwordLoginEnabled}
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint}>
|
||||
<!-- Backend OIDC authorize endpoint (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint} rel="external">
|
||||
{t(
|
||||
'auth.sso_login_provider',
|
||||
{ provider: oidc.provider_name ?? 'SSO' },
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts">
|
||||
// Route-scoped styles: kept off the global critical path (Vite code-splits
|
||||
// this into the /music route chunk, loaded only when this page renders).
|
||||
import '$lib/styles/ported/music.css';
|
||||
import { useSelection } from '$lib/composables/useSelection.svelte';
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
// Route-scoped auth styles (this page uses the .auth-* classes).
|
||||
import '$lib/styles/ported/auth.css';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
||||
@@ -86,7 +88,8 @@
|
||||
{#if passwordLoginEnabled}
|
||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||
{/if}
|
||||
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`}>
|
||||
<!-- Backend Nextcloud Login Flow v2 OIDC handshake (not a SvelteKit route). -->
|
||||
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`} rel="external">
|
||||
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
|
||||
const groups = $derived.by(() => {
|
||||
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
|
||||
// Transient scratch map built inside $derived.by and discarded — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const index = new Map<string, number>();
|
||||
for (const p of items) {
|
||||
const d = new Date(photoTimestamp(p));
|
||||
|
||||
@@ -840,7 +840,7 @@
|
||||
|
||||
.info-value {
|
||||
font-weight: var(--weight-medium, 500);
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.storage-stats {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||
import { errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||
import {
|
||||
addFavorite,
|
||||
@@ -143,7 +145,7 @@
|
||||
|
||||
function open(entry: ResourceEntry) {
|
||||
if (entry.kind === 'folder') {
|
||||
goto(`/files/${entry.id}`);
|
||||
goto(resolve(`/files/${entry.id}`));
|
||||
return;
|
||||
}
|
||||
const item = byId.get(entry.id);
|
||||
@@ -155,7 +157,7 @@
|
||||
|
||||
async function toggleFavorite(entry: ResourceEntry) {
|
||||
const isFav = favoriteIds.has(entry.id);
|
||||
const next = new Set(favoriteIds);
|
||||
const next = new SvelteSet(favoriteIds);
|
||||
if (isFav) next.delete(entry.id);
|
||||
else next.add(entry.id);
|
||||
favoriteIds = next;
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
<div class="share__center">
|
||||
<Icon name="file" class="share__big-icon" />
|
||||
<h1>{meta?.item_name}</h1>
|
||||
<a class="share__btn" href={shareDownloadUrl(token)} download>
|
||||
<a class="share__btn" href={shareDownloadUrl(token)} download rel="external">
|
||||
{t('share.download', 'Download')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -307,7 +307,7 @@
|
||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||
>
|
||||
</div>
|
||||
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
|
||||
<a class="share__btn" href={shareZipUrl(token, folderId)} download rel="external">
|
||||
<Icon name="file-archive" />
|
||||
{t('share.download_zip', 'Download ZIP')}
|
||||
</a>
|
||||
@@ -367,7 +367,12 @@
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
|
||||
<a
|
||||
class="card"
|
||||
href={shareFileUrl(token, f.id)}
|
||||
target="_blank"
|
||||
rel="external noreferrer"
|
||||
>
|
||||
<span class="card__thumb"><Icon name="file" class="card__icon" /></span>
|
||||
<span class="card__name">{f.name}</span>
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { searchFiles } from '$lib/api/endpoints/search';
|
||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||
@@ -151,7 +153,7 @@
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(`/files/${folder.id}`);
|
||||
goto(resolve(`/files/${folder.id}`));
|
||||
}
|
||||
|
||||
function openFile(file: FileItem) {
|
||||
@@ -160,6 +162,18 @@
|
||||
|
||||
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
|
||||
|
||||
// Flatten folders + files into one list so the results render through a single
|
||||
// windowed list (only the visible rows hit the DOM, even for 100s of hits).
|
||||
type SearchEntry = { kind: 'folder'; folder: FolderItem } | { kind: 'file'; file: FileItem };
|
||||
const entries = $derived<SearchEntry[]>(
|
||||
results
|
||||
? [
|
||||
...results.folders.map((folder) => ({ kind: 'folder' as const, folder })),
|
||||
...results.files.map((file) => ({ kind: 'file' as const, file }))
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// re-run when query, sort, scope, or any filter changes
|
||||
void sortBy;
|
||||
@@ -256,43 +270,49 @@
|
||||
<div>{t('files.col_modified', 'Modified')}</div>
|
||||
</div>
|
||||
|
||||
{#each results.folders as folder (folder.id)}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFolder(folder)}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
||||
<span>{folder.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{folder.path}</div>
|
||||
<div class="size-cell">—</div>
|
||||
<div class="date-cell">{formatDate(folder.modified_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each results.files as file (file.id)}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFile(file)}
|
||||
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon {fileIconKindClass(iconNameFromClass(file.icon_class))}"
|
||||
><Icon name={iconNameFromClass(file.icon_class)} /></span
|
||||
<VirtualList
|
||||
items={entries}
|
||||
rowHeight={56}
|
||||
key={(e) => (e.kind === 'folder' ? e.folder.id : e.file.id)}
|
||||
>
|
||||
{#snippet row(e)}
|
||||
{#if e.kind === 'folder'}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFolder(e.folder)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||
>
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{file.path}</div>
|
||||
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
|
||||
<div class="date-cell">{formatDate(file.modified_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="name-cell">
|
||||
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
||||
<span>{e.folder.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{e.folder.path}</div>
|
||||
<div class="size-cell">—</div>
|
||||
<div class="date-cell">{formatDate(e.folder.modified_at)}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="file-item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => openFile(e.file)}
|
||||
onkeydown={(ev) => ev.key === 'Enter' && openFile(e.file)}
|
||||
>
|
||||
<div class="name-cell">
|
||||
<span class="file-icon {fileIconKindClass(iconNameFromClass(e.file.icon_class))}"
|
||||
><Icon name={iconNameFromClass(e.file.icon_class)} /></span
|
||||
>
|
||||
<span>{e.file.name}</span>
|
||||
</div>
|
||||
<div class="path-cell">{e.file.path}</div>
|
||||
<div class="size-cell">{e.file.size != null ? formatBytes(e.file.size) : ''}</div>
|
||||
<div class="date-cell">{formatDate(e.file.modified_at)}</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { errorMessage } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites';
|
||||
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
||||
@@ -114,7 +115,7 @@
|
||||
|
||||
function open(entry: ResourceEntry) {
|
||||
if (entry.kind === 'folder') {
|
||||
goto(`/files/${entry.id}`);
|
||||
goto(resolve(`/files/${entry.id}`));
|
||||
return;
|
||||
}
|
||||
const item = byId.get(entry.id);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
displayRole,
|
||||
@@ -88,6 +89,8 @@
|
||||
|
||||
const lanes = $derived.by((): Lane[] => {
|
||||
const out: Lane[] = [];
|
||||
// Transient scratch map built inside $derived.by and discarded — not reactive state.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const byKey = new Map<string, Lane>();
|
||||
const ensure = (key: string, header: Lane['header']): Lane => {
|
||||
let lane = byKey.get(key);
|
||||
@@ -179,7 +182,7 @@
|
||||
}
|
||||
|
||||
function openResource(item: OutgoingGrantItem) {
|
||||
if (item.resource_type === 'folder') goto(`/files/${item.resource.id}`);
|
||||
if (item.resource_type === 'folder') goto(resolve(`/files/${item.resource.id}`));
|
||||
else window.open(fileInlineUrl(item.resource.id), '_blank', 'noopener');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user