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:
@@ -64,7 +64,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26.3.1
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 26.3.1
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
- name: Build SPA (Vite -> static-dist/)
|
- name: Build SPA (Vite -> static-dist/)
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ npm run test:unit # Vitest (just fe-test)
|
|||||||
npm run format # prettier --write .
|
npm run format # prettier --write .
|
||||||
```
|
```
|
||||||
|
|
||||||
`just dev` runs the backend and the Vite dev server together. CI uses **Node 24**; Node 22+ works locally.
|
`just dev` runs the backend and the Vite dev server together. CI uses **Node 26**; Node 24+ works locally.
|
||||||
|
|
||||||
## Frontend Architecture (`frontend/src/`)
|
## Frontend Architecture (`frontend/src/`)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ RUN apk --no-cache upgrade && \
|
|||||||
# ─── Stage 1b: Build the SvelteKit frontend (Vite) ───────────────────────────
|
# ─── Stage 1b: Build the SvelteKit frontend (Vite) ───────────────────────────
|
||||||
# Produces the SPA in /static-dist. `npm ci` is cached unless the lockfile
|
# Produces the SPA in /static-dist. `npm ci` is cached unless the lockfile
|
||||||
# changes; the Rust build no longer bundles assets (see build.rs).
|
# changes; the Rust build no longer bundles assets (see build.rs).
|
||||||
FROM node:24-alpine AS frontend
|
FROM node:26.3.1-alpine3.24 AS frontend
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export default ts.config(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte'],
|
// `.svelte` components and `.svelte.ts`/`.svelte.js` rune modules are all
|
||||||
|
// parsed by svelte-eslint-parser under eslint-plugin-svelte v3; it needs the
|
||||||
|
// TS parser for the embedded/whole-file TypeScript or it chokes on type syntax.
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: ts.parser
|
parser: ts.parser
|
||||||
|
|||||||
Generated
+800
-1210
File diff suppressed because it is too large
Load Diff
+23
-23
@@ -18,28 +18,28 @@
|
|||||||
"test:unit:watch": "vitest"
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.6",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.15.0",
|
"@sveltejs/kit": "^2.66.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.1.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.2.6",
|
"@testing-library/svelte": "^5.4.0",
|
||||||
"@types/node": "^22.19.21",
|
"@types/node": "^26.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^10.5.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-svelte": "^2.46.1",
|
"eslint-plugin-svelte": "^3.19.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^29.1.1",
|
||||||
"postcss-html": "^1.7.0",
|
"postcss-html": "^1.8.1",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.8.4",
|
||||||
"prettier-plugin-svelte": "^3.3.2",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"stylelint": "^16.12.0",
|
"stylelint": "^17.13.0",
|
||||||
"stylelint-config-standard": "^36.0.1",
|
"stylelint-config-standard": "^40.0.0",
|
||||||
"svelte": "^5.16.0",
|
"svelte": "^5.56.3",
|
||||||
"svelte-check": "^4.1.1",
|
"svelte-check": "^4.6.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.18.2",
|
"typescript-eslint": "^8.61.1",
|
||||||
"vite": "^6.0.6",
|
"vite": "^6.4.3",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ function jsonResponse(status: number, body: unknown = {}): Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('createApiFetch — 401 refresh/retry parity', () => {
|
describe('createApiFetch — 401 refresh/retry parity', () => {
|
||||||
let onSessionExpired: ReturnType<typeof vi.fn>;
|
let onSessionExpired: ReturnType<typeof vi.fn<() => void>>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onSessionExpired = vi.fn();
|
onSessionExpired = vi.fn<() => void>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes through a non-401 response untouched (no refresh)', async () => {
|
it('passes through a non-401 response untouched (no refresh)', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { logout } from '$lib/api/endpoints/auth';
|
import { logout } from '$lib/api/endpoints/auth';
|
||||||
import { searchFiles } from '$lib/api/endpoints/search';
|
import { searchFiles } from '$lib/api/endpoints/search';
|
||||||
@@ -25,7 +26,15 @@
|
|||||||
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
||||||
|
|
||||||
interface NavLink {
|
interface NavLink {
|
||||||
href: string;
|
href:
|
||||||
|
| '/files'
|
||||||
|
| '/shared'
|
||||||
|
| '/shared-with-me'
|
||||||
|
| '/recent'
|
||||||
|
| '/favorites'
|
||||||
|
| '/photos'
|
||||||
|
| '/music'
|
||||||
|
| '/trash';
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
/** Stable key driving the per-section icon colour (see sidebar.css). */
|
/** Stable key driving the per-section icon colour (see sidebar.css). */
|
||||||
@@ -126,7 +135,7 @@
|
|||||||
if (q) {
|
if (q) {
|
||||||
suggestOpen = false;
|
suggestOpen = false;
|
||||||
searchActive = false;
|
searchActive = false;
|
||||||
goto(`/search?q=${encodeURIComponent(q)}`);
|
goto(resolve(`/search?q=${encodeURIComponent(q)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +178,7 @@
|
|||||||
|
|
||||||
function pickSuggestion(s: Suggestion) {
|
function pickSuggestion(s: Suggestion) {
|
||||||
suggestOpen = false;
|
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');
|
else window.open(fileInlineUrl(s.item.id), '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +239,7 @@
|
|||||||
/* clear locally regardless */
|
/* clear locally regardless */
|
||||||
}
|
}
|
||||||
session.reset();
|
session.reset();
|
||||||
await goto('/login');
|
await goto(resolve('/login'));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -259,7 +268,7 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="sidebar" class:open={sidebarOpen}>
|
<div class="sidebar" class:open={sidebarOpen}>
|
||||||
<a href="/files" class="logo-container">
|
<a href={resolve('/files')} class="logo-container">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<svg viewBox="95 67 320 320" aria-hidden="true">
|
<svg viewBox="95 67 320 320" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
@@ -275,7 +284,7 @@
|
|||||||
<a
|
<a
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
class:active={active(link.href)}
|
class:active={active(link.href)}
|
||||||
href={link.href}
|
href={resolve(link.href)}
|
||||||
data-section={link.section}
|
data-section={link.section}
|
||||||
onclick={() => (sidebarOpen = false)}
|
onclick={() => (sidebarOpen = false)}
|
||||||
>
|
>
|
||||||
@@ -546,15 +555,15 @@
|
|||||||
<div class="user-menu-divider"></div>
|
<div class="user-menu-divider"></div>
|
||||||
|
|
||||||
{#if isAdmin}
|
{#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>
|
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
|
||||||
</a>
|
</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" />
|
<Icon name="user-group" />
|
||||||
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
|
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/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>
|
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { logout } from '$lib/api/endpoints/auth';
|
import { logout } from '$lib/api/endpoints/auth';
|
||||||
import { searchFiles } from '$lib/api/endpoints/search';
|
import { searchFiles } from '$lib/api/endpoints/search';
|
||||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||||
@@ -56,10 +57,27 @@
|
|||||||
prevFocus = null;
|
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 () => {
|
return () => {
|
||||||
close();
|
close();
|
||||||
void goto(path);
|
void goto(resolve(path));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +88,7 @@
|
|||||||
*/
|
*/
|
||||||
function uploadFiles() {
|
function uploadFiles() {
|
||||||
close();
|
close();
|
||||||
void goto('/files').then(() => {
|
void goto(resolve('/files')).then(() => {
|
||||||
window.dispatchEvent(new CustomEvent('oxicloud:upload-files'));
|
window.dispatchEvent(new CustomEvent('oxicloud:upload-files'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,7 +171,7 @@
|
|||||||
/* clear locally regardless */
|
/* clear locally regardless */
|
||||||
}
|
}
|
||||||
session.reset();
|
session.reset();
|
||||||
await goto('/login');
|
await goto(resolve('/login'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
} catch {
|
} catch {
|
||||||
/* private mode / quota — silently fall back to default */
|
/* private mode / quota — silently fall back to default */
|
||||||
}
|
}
|
||||||
await goto(`/files/${d.root_folder_id}`);
|
await goto(resolve(`/files/${d.root_folder_id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
<span class="drive-picker__name">{d.name}</span>
|
<span class="drive-picker__name">{d.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`/config/drive/${d.id}`}
|
href={resolve(`/config/drive/${d.id}`)}
|
||||||
class="drive-picker__settings"
|
class="drive-picker__settings"
|
||||||
title={t('drive.settings_aria', 'Drive settings')}
|
title={t('drive.settings_aria', 'Drive settings')}
|
||||||
aria-label={t('drive.settings_aria', 'Drive settings')}
|
aria-label={t('drive.settings_aria', 'Drive settings')}
|
||||||
|
|||||||
@@ -196,7 +196,12 @@
|
|||||||
{t('files.edit', 'Edit')}
|
{t('files.edit', 'Edit')}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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" />
|
<Icon name="download" />
|
||||||
{t('common.download', 'Download')}
|
{t('common.download', 'Download')}
|
||||||
</a>
|
</a>
|
||||||
@@ -204,7 +209,7 @@
|
|||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
href={fileInlineUrl(file.id)}
|
href={fileInlineUrl(file.id)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="external noreferrer"
|
||||||
>
|
>
|
||||||
<Icon name="external-link-alt" />
|
<Icon name="external-link-alt" />
|
||||||
</a>
|
</a>
|
||||||
@@ -381,7 +386,7 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
overflow-wrap: break-word;
|
||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
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';
|
||||||
@@ -195,6 +196,8 @@
|
|||||||
const bucketOf = activeGroup?.bucketOf;
|
const bucketOf = activeGroup?.bucketOf;
|
||||||
if (!bucketOf) return [{ key: '', label: '', rows: items }];
|
if (!bucketOf) return [{ key: '', label: '', rows: items }];
|
||||||
const order: string[] = [];
|
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[]>();
|
const map = new Map<string, ResourceEntry[]>();
|
||||||
for (const entry of items) {
|
for (const entry of items) {
|
||||||
const k = bucketOf(entry) ?? '∅';
|
const k = bucketOf(entry) ?? '∅';
|
||||||
@@ -213,24 +216,24 @@
|
|||||||
const grouped = $derived(!!activeGroup?.bucketOf);
|
const grouped = $derived(!!activeGroup?.bucketOf);
|
||||||
|
|
||||||
// ── Selection ─────────────────────────────────────────────────────────────
|
// ── 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) {
|
function toggleSelected(id: string) {
|
||||||
const next = new Set(selected);
|
if (selected.has(id)) selected.delete(id);
|
||||||
if (next.has(id)) next.delete(id);
|
else selected.add(id);
|
||||||
else next.add(id);
|
onselectionchange?.(selected);
|
||||||
selected = next;
|
|
||||||
onselectionchange?.(next);
|
|
||||||
}
|
}
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selected = new Set();
|
selected.clear();
|
||||||
onselectionchange?.(selected);
|
onselectionchange?.(selected);
|
||||||
}
|
}
|
||||||
const allSelected = $derived(items.length > 0 && selected.size === items.length);
|
const allSelected = $derived(items.length > 0 && selected.size === items.length);
|
||||||
function toggleSelectAll() {
|
function toggleSelectAll() {
|
||||||
if (allSelected) clearSelection();
|
if (allSelected) clearSelection();
|
||||||
else {
|
else {
|
||||||
selected = new Set(items.map((i) => i.id));
|
selected.clear();
|
||||||
|
for (const i of items) selected.add(i.id);
|
||||||
onselectionchange?.(selected);
|
onselectionchange?.(selected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,15 +243,13 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const ids = new Set(items.map((i) => i.id));
|
const ids = new Set(items.map((i) => i.id));
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = new Set<string>();
|
|
||||||
for (const id of selected) {
|
for (const id of selected) {
|
||||||
if (ids.has(id)) next.add(id);
|
if (!ids.has(id)) {
|
||||||
else changed = true;
|
selected.delete(id);
|
||||||
}
|
changed = true;
|
||||||
if (changed) {
|
}
|
||||||
selected = next;
|
|
||||||
onselectionchange?.(next);
|
|
||||||
}
|
}
|
||||||
|
if (changed) onselectionchange?.(selected);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Right-click context menu ──────────────────────────────────────────────
|
// ── Right-click context menu ──────────────────────────────────────────────
|
||||||
@@ -420,9 +421,17 @@
|
|||||||
{@render listHeader()}
|
{@render listHeader()}
|
||||||
{#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)}
|
{#if filesStore.viewMode === 'list'}
|
||||||
{@render row(entry)}
|
<!-- Window each section's rows so a large grouped list (e.g. a big
|
||||||
{/each}
|
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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if filesStore.viewMode === 'list'}
|
{:else if filesStore.viewMode === 'list'}
|
||||||
|
|||||||
@@ -89,6 +89,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupGrants(grants: Grant[]): Member[] {
|
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<
|
const bySubject = new Map<
|
||||||
string,
|
string,
|
||||||
{ subject: GrantSubject; role: ShareRole; ids: string[]; expiry: string | null }
|
{ subject: GrantSubject; role: ShareRole; ids: string[]; expiry: string | null }
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export class OwnerCache {
|
|||||||
|
|
||||||
/** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */
|
/** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */
|
||||||
async resolve(ids: Iterable<string | null | undefined>): Promise<void> {
|
async resolve(ids: Iterable<string | null | undefined>): Promise<void> {
|
||||||
|
// Transient scratch Set for dedup only — built, spread to an array, and
|
||||||
|
// discarded in this call; never read reactively, so a plain Set is correct.
|
||||||
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||||
const pending = [...new Set([...ids].filter((id): id is string => !!id))].filter(
|
const pending = [...new Set([...ids].filter((id): id is string => !!id))].filter(
|
||||||
(id) => !this.#names[id]
|
(id) => !this.#names[id]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive multi-select over string ids. Backs the repeated
|
* Reactive multi-select over string ids. Backs the repeated
|
||||||
* `let selected = $state(new Set()); function toggle(id) { … }` pattern used by
|
* `let selected = $state(new Set()); function toggle(id) { … }` pattern used by
|
||||||
* the photos grid, music picker and other list views with one source of truth.
|
* the photos grid, music picker and other list views with one source of truth.
|
||||||
*
|
*
|
||||||
* Mutations swap in a fresh Set so `$derived`/template reads re-run.
|
* Backed by a reactive {@link SvelteSet}, so in-place mutations (`add`/`delete`)
|
||||||
|
* drive `$derived`/template reads without copying the set.
|
||||||
*/
|
*/
|
||||||
export class Selection {
|
export class Selection {
|
||||||
#ids = $state<Set<string>>(new Set());
|
#ids = new SvelteSet<string>();
|
||||||
|
|
||||||
/** The live selection set (read-only intent — mutate via the methods). */
|
/** The live selection set (read-only intent — mutate via the methods). */
|
||||||
get ids(): Set<string> {
|
get ids(): SvelteSet<string> {
|
||||||
return this.#ids;
|
return this.#ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,31 +34,26 @@ export class Selection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle(id: string): void {
|
toggle(id: string): void {
|
||||||
const next = new Set(this.#ids);
|
if (this.#ids.has(id)) this.#ids.delete(id);
|
||||||
if (next.has(id)) next.delete(id);
|
else this.#ids.add(id);
|
||||||
else next.add(id);
|
|
||||||
this.#ids = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add(id: string): void {
|
add(id: string): void {
|
||||||
if (this.#ids.has(id)) return;
|
this.#ids.add(id);
|
||||||
this.#ids = new Set(this.#ids).add(id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(id: string): void {
|
delete(id: string): void {
|
||||||
if (!this.#ids.has(id)) return;
|
this.#ids.delete(id);
|
||||||
const next = new Set(this.#ids);
|
|
||||||
next.delete(id);
|
|
||||||
this.#ids = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Replace the whole selection. */
|
/** Replace the whole selection. */
|
||||||
set(ids: Iterable<string>): void {
|
set(ids: Iterable<string>): void {
|
||||||
this.#ids = new Set(ids);
|
this.#ids.clear();
|
||||||
|
for (const id of ids) this.#ids.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
if (this.#ids.size) this.#ids = new Set();
|
this.#ids.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* section, selection). Dialog/context-menu targets stay component-local until a
|
* section, selection). Dialog/context-menu targets stay component-local until a
|
||||||
* view proves they must be shared.
|
* view proves they must be shared.
|
||||||
*/
|
*/
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import type { FolderItem } from '$lib/api/types';
|
import type { FolderItem } from '$lib/api/types';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
@@ -85,7 +86,8 @@ class FilesStore {
|
|||||||
viewMode = $state<ViewMode>(readViewMode());
|
viewMode = $state<ViewMode>(readViewMode());
|
||||||
section = $state<Section>('files');
|
section = $state<Section>('files');
|
||||||
isSearchMode = $state(false);
|
isSearchMode = $state(false);
|
||||||
selection = $state<Set<string>>(new Set());
|
// Reactive set: in-place mutations below drive template/$derived reads.
|
||||||
|
selection = new SvelteSet<string>();
|
||||||
|
|
||||||
setViewMode(mode: ViewMode): void {
|
setViewMode(mode: ViewMode): void {
|
||||||
this.viewMode = mode;
|
this.viewMode = mode;
|
||||||
@@ -93,7 +95,7 @@ class FilesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection(): void {
|
clearSelection(): void {
|
||||||
this.selection = new Set();
|
this.selection.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft ceiling so the per-item toggle can't grow the set without bound.
|
// Soft ceiling so the per-item toggle can't grow the set without bound.
|
||||||
@@ -102,10 +104,8 @@ class FilesStore {
|
|||||||
static readonly MAX_SELECTION = 10_000;
|
static readonly MAX_SELECTION = 10_000;
|
||||||
|
|
||||||
toggleSelected(id: string): void {
|
toggleSelected(id: string): void {
|
||||||
const next = new Set(this.selection);
|
if (this.selection.has(id)) this.selection.delete(id);
|
||||||
if (next.has(id)) next.delete(id);
|
else if (this.selection.size < FilesStore.MAX_SELECTION) this.selection.add(id);
|
||||||
else if (next.size < FilesStore.MAX_SELECTION) next.add(id);
|
|
||||||
this.selection = next;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,6 @@
|
|||||||
@import url('./ported/skeleton.css');
|
@import url('./ported/skeleton.css');
|
||||||
@import url('./ported/notifications.css');
|
@import url('./ported/notifications.css');
|
||||||
@import url('./ported/userMenu.css');
|
@import url('./ported/userMenu.css');
|
||||||
@import url('./ported/auth.css');
|
/* auth.css and music.css are route-scoped — imported by their pages
|
||||||
@import url('./ported/music.css');
|
* (routes/login, device, nextcloud/login, music) so they stay off the
|
||||||
|
* global critical path. */
|
||||||
|
|||||||
@@ -8,6 +8,30 @@ export interface RelativeTimeOptions {
|
|||||||
invalidAsString?: boolean;
|
invalidAsString?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unit thresholds in seconds, largest first. Hoisted so it isn't rebuilt per call. */
|
||||||
|
const RELATIVE_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
||||||
|
['year', 31536000],
|
||||||
|
['month', 2592000],
|
||||||
|
['week', 604800],
|
||||||
|
['day', 86400],
|
||||||
|
['hour', 3600],
|
||||||
|
['minute', 60]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily-built, reused `Intl.RelativeTimeFormat`. Constructing one is ~orders of
|
||||||
|
* magnitude costlier than a `format()` call, and {@link relativeTimeAgo} runs
|
||||||
|
* once per row per render across large lists — so we build it once (browser
|
||||||
|
* default locale, matching the previous `undefined` argument) and reuse it.
|
||||||
|
*/
|
||||||
|
let relativeFormatter: Intl.RelativeTimeFormat | undefined;
|
||||||
|
function getRelativeFormatter(): Intl.RelativeTimeFormat {
|
||||||
|
if (!relativeFormatter) {
|
||||||
|
relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
|
}
|
||||||
|
return relativeFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Locale-aware relative "time ago" via `Intl.RelativeTimeFormat`.
|
* Locale-aware relative "time ago" via `Intl.RelativeTimeFormat`.
|
||||||
*
|
*
|
||||||
@@ -27,16 +51,8 @@ export function relativeTimeAgo(
|
|||||||
|
|
||||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
||||||
const abs = Math.abs(diffSec);
|
const abs = Math.abs(diffSec);
|
||||||
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
const rtf = getRelativeFormatter();
|
||||||
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
for (const [unit, secs] of RELATIVE_UNITS) {
|
||||||
['year', 31536000],
|
|
||||||
['month', 2592000],
|
|
||||||
['week', 604800],
|
|
||||||
['day', 86400],
|
|
||||||
['hour', 3600],
|
|
||||||
['minute', 60]
|
|
||||||
];
|
|
||||||
for (const [unit, secs] of units) {
|
|
||||||
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
||||||
}
|
}
|
||||||
return rtf.format(diffSec, 'second');
|
return rtf.format(diffSec, 'second');
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { Pathname } from '$app/types';
|
||||||
import { page, updated } from '$app/state';
|
import { page, updated } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import '$lib/styles/app.css';
|
import '$lib/styles/app.css';
|
||||||
@@ -40,8 +42,11 @@
|
|||||||
|
|
||||||
// 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('#/')) {
|
||||||
|
// 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);
|
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();
|
await session.load();
|
||||||
ready = true;
|
ready = true;
|
||||||
@@ -53,7 +58,7 @@
|
|||||||
if (!ready) return;
|
if (!ready) return;
|
||||||
const path = page.url.pathname;
|
const path = page.url.pathname;
|
||||||
if (!session.isAuthenticated && !isPublic(path)) {
|
if (!session.isAuthenticated && !isPublic(path)) {
|
||||||
void goto(`/login?redirect=${encodeURIComponent(path)}`, { replaceState: true });
|
void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
// `default_for_user` matches the caller).
|
// `default_for_user` matches the caller).
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const target = session.isExternalUser ? '/shared-with-me' : '/files';
|
const target = session.isExternalUser ? '/shared-with-me' : '/files';
|
||||||
void goto(target, { replaceState: true });
|
void goto(resolve(target), { replaceState: true });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2134,7 +2134,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-msg {
|
.log-msg {
|
||||||
word-break: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logs-pager {
|
.logs-pager {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
<p class="muted">
|
<p class="muted">
|
||||||
{t('drive.not_found_body', "This drive doesn't exist or you don't have access to it.")}
|
{t('drive.not_found_body', "This drive doesn't exist or you don't have access to it.")}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>
|
<h1>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 { errorMessage } from '$lib/utils/errors';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||||
import { errorToast } from '$lib/utils/errors';
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
dateBucket,
|
dateBucket,
|
||||||
@@ -132,7 +133,7 @@
|
|||||||
|
|
||||||
function open(entry: ResourceEntry) {
|
function open(entry: ResourceEntry) {
|
||||||
if (entry.kind === 'folder') {
|
if (entry.kind === 'folder') {
|
||||||
goto(`/files/${entry.id}`);
|
goto(resolve(`/files/${entry.id}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = byId.get(entry.id);
|
const item = byId.get(entry.id);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import {
|
import {
|
||||||
cacheFolder,
|
cacheFolder,
|
||||||
@@ -131,7 +133,7 @@
|
|||||||
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, reverted on failure.
|
// Optimistic toggle, reverted on failure.
|
||||||
const next = new Set(favoriteIds);
|
const next = new SvelteSet(favoriteIds);
|
||||||
if (isFav) next.delete(id);
|
if (isFav) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
favoriteIds = next;
|
favoriteIds = next;
|
||||||
@@ -140,7 +142,7 @@
|
|||||||
else await addFavorite(kind, id);
|
else await addFavorite(kind, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
const reverted = new Set(favoriteIds);
|
const reverted = new SvelteSet(favoriteIds);
|
||||||
if (isFav) reverted.add(id);
|
if (isFav) reverted.add(id);
|
||||||
else reverted.delete(id);
|
else reverted.delete(id);
|
||||||
favoriteIds = reverted;
|
favoriteIds = reverted;
|
||||||
@@ -181,7 +183,7 @@
|
|||||||
|
|
||||||
// External users have no home folder; send them to shared-with-me.
|
// External users have no home folder; send them to shared-with-me.
|
||||||
if (session.isExternalUser && pathSegments.length === 0) {
|
if (session.isExternalUser && pathSegments.length === 0) {
|
||||||
await goto('/shared-with-me', { replaceState: true });
|
await goto(resolve('/shared-with-me'), { replaceState: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const home = await session.loadHomeFolder();
|
const home = await session.loadHomeFolder();
|
||||||
@@ -195,7 +197,7 @@
|
|||||||
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
typeof localStorage !== 'undefined' ? localStorage.getItem('oxi-last-drive-root') : null;
|
||||||
const target = last ?? home;
|
const target = last ?? home;
|
||||||
if (target) {
|
if (target) {
|
||||||
await goto(`/files/${target}`, { replaceState: true });
|
await goto(resolve(`/files/${target}`), { replaceState: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,11 +269,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFolder(folder: FolderItem) {
|
function openFolder(folder: FolderItem) {
|
||||||
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
|
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
|
||||||
}
|
|
||||||
|
|
||||||
function crumbHref(index: number): string {
|
|
||||||
return `/files/${pathSegments.slice(0, index + 1).join('/')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onNewFolder() {
|
async function onNewFolder() {
|
||||||
@@ -674,6 +672,9 @@
|
|||||||
// reflects the param into viewerOpen/viewerFile.
|
// reflects the param into viewerOpen/viewerFile.
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
url.searchParams.set('file', file.id);
|
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 });
|
void goto(url, { keepFocus: true, noScroll: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,6 +706,8 @@
|
|||||||
if (!viewerOpen && hasParam) {
|
if (!viewerOpen && hasParam) {
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
url.searchParams.delete('file');
|
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 });
|
void goto(url, { keepFocus: true, noScroll: true, replaceState: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -728,7 +731,7 @@
|
|||||||
let selectionAnchor = $state<string | null>(null);
|
let selectionAnchor = $state<string | null>(null);
|
||||||
|
|
||||||
function toggleSelected(id: string) {
|
function toggleSelected(id: string) {
|
||||||
const next = new Set(selected);
|
const next = new SvelteSet(selected);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(id);
|
else next.add(id);
|
||||||
selected = next;
|
selected = next;
|
||||||
@@ -965,6 +968,9 @@
|
|||||||
// (DownloadURL can only point at a GET URL); file_ids/folder_ids are CSV.
|
// (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 fileIds = items.filter((i) => i.kind === 'file').map((i) => i.id);
|
||||||
const folderIds = items.filter((i) => i.kind === 'folder').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();
|
const params = new URLSearchParams();
|
||||||
if (fileIds.length) params.set('file_ids', fileIds.join(','));
|
if (fileIds.length) params.set('file_ids', fileIds.join(','));
|
||||||
if (folderIds.length) params.set('folder_ids', folderIds.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
|
// 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
|
// file's own folder id (handles deep-link / search contexts where the file's
|
||||||
// folder differs from the current path).
|
// folder differs from the current path).
|
||||||
goto(`/files/${file.folder_id}`);
|
goto(resolve(`/files/${file.folder_id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Download a folder as a zip archive ────────────────────────────────────
|
// ── Download a folder as a zip archive ────────────────────────────────────
|
||||||
@@ -1209,6 +1215,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map each relative directory path to its created folder id; '' = current.
|
// 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]]);
|
const dirIds = new Map<string, string | null>([['', currentId]]);
|
||||||
|
|
||||||
async function ensureDir(relDir: string): Promise<string | null> {
|
async function ensureDir(relDir: string): Promise<string | null> {
|
||||||
@@ -1342,6 +1350,10 @@
|
|||||||
// within each lane. Lanes appear in first-seen order (folders precede files).
|
// within each lane. Lanes appear in first-seen order (folders precede files).
|
||||||
const groups = $derived.by<ResourceGroup[]>(() => {
|
const groups = $derived.by<ResourceGroup[]>(() => {
|
||||||
if (groupBy === '') return [];
|
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 map = new Map<string, ResourceGroup>();
|
||||||
const ensure = (key: string): ResourceGroup => {
|
const ensure = (key: string): ResourceGroup => {
|
||||||
let g = map.get(key);
|
let g = map.get(key);
|
||||||
@@ -1575,7 +1587,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
href={crumbHref(i)}
|
href={resolve(`/files/${pathSegments.slice(0, i + 1).join('/')}`)}
|
||||||
class="breadcrumb-item breadcrumb-link"
|
class="breadcrumb-item breadcrumb-link"
|
||||||
class:breadcrumb-home={i === 0}
|
class:breadcrumb-home={i === 0}
|
||||||
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
title={i === 0 ? t('breadcrumb.home', 'Home') : undefined}
|
||||||
@@ -1891,6 +1903,7 @@
|
|||||||
<a
|
<a
|
||||||
class="btn-action"
|
class="btn-action"
|
||||||
href={fileDownloadUrl(file.id)}
|
href={fileDownloadUrl(file.id)}
|
||||||
|
rel="external"
|
||||||
download
|
download
|
||||||
title={t('common.download', 'Download')}
|
title={t('common.download', 'Download')}
|
||||||
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
|
||||||
@@ -1936,7 +1949,7 @@
|
|||||||
<ShareDialog
|
<ShareDialog
|
||||||
bind:open={shareOpen}
|
bind:open={shareOpen}
|
||||||
item={actionTarget}
|
item={actionTarget}
|
||||||
onshared={(id) => (sharedIds = new Set(sharedIds).add(id))}
|
onshared={(id) => (sharedIds = new SvelteSet(sharedIds).add(id))}
|
||||||
/>
|
/>
|
||||||
{#if fileViewer.component}
|
{#if fileViewer.component}
|
||||||
{@const FileViewer = fileViewer.component}
|
{@const FileViewer = fileViewer.component}
|
||||||
@@ -1967,7 +1980,7 @@
|
|||||||
onclick={() => {
|
onclick={() => {
|
||||||
const id = ctxTarget!.id;
|
const id = ctxTarget!.id;
|
||||||
closeContext();
|
closeContext();
|
||||||
goto(`/files/${[...pathSegments, id].join('/')}`);
|
goto(resolve(`/files/${[...pathSegments, id].join('/')}`));
|
||||||
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
}}><Icon name="folder-open" /> {t('files.open', 'Open')}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@@ -2013,6 +2026,7 @@
|
|||||||
class="ctx-item"
|
class="ctx-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
href={fileDownloadUrl(ctxTarget.id)}
|
href={fileDownloadUrl(ctxTarget.id)}
|
||||||
|
rel="external"
|
||||||
download
|
download
|
||||||
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
onclick={closeContext}><Icon name="download" /> {t('common.download', 'Download')}</a
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
<script lang="ts">
|
<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 { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import type { Pathname } from '$app/types';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
exchangeOidcCode,
|
exchangeOidcCode,
|
||||||
@@ -65,7 +70,10 @@
|
|||||||
let oidc = $state<OidcProviders>({ enabled: false });
|
let oidc = $state<OidcProviders>({ enabled: false });
|
||||||
const passwordLoginEnabled = $derived(oidc.password_login_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(
|
const matchState = $derived(
|
||||||
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
|
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
|
||||||
);
|
);
|
||||||
@@ -100,7 +108,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.user = data.user;
|
session.user = data.user;
|
||||||
await goto(redirectTarget, { replaceState: true });
|
await goto(resolve(redirectTarget), { replaceState: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
|
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -196,7 +204,7 @@
|
|||||||
const user = await exchangeOidcCode(oidcCode);
|
const user = await exchangeOidcCode(oidcCode);
|
||||||
if (user) {
|
if (user) {
|
||||||
session.user = user;
|
session.user = user;
|
||||||
await goto(redirectTarget, { replaceState: true });
|
await goto(resolve(redirectTarget), { replaceState: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Exchange failed — fall through to the normal login UI.
|
// Exchange failed — fall through to the normal login UI.
|
||||||
@@ -207,7 +215,7 @@
|
|||||||
const me = await fetchMe();
|
const me = await fetchMe();
|
||||||
if (me) {
|
if (me) {
|
||||||
session.user = me;
|
session.user = me;
|
||||||
await goto(redirectTarget, { replaceState: true });
|
await goto(resolve(redirectTarget), { replaceState: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -362,7 +370,8 @@
|
|||||||
{#if passwordLoginEnabled}
|
{#if passwordLoginEnabled}
|
||||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||||
{/if}
|
{/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(
|
{t(
|
||||||
'auth.sso_login_provider',
|
'auth.sso_login_provider',
|
||||||
{ provider: oidc.provider_name ?? 'SSO' },
|
{ provider: oidc.provider_name ?? 'SSO' },
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<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 { useSelection } from '$lib/composables/useSelection.svelte';
|
||||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<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 { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
import { getOidcProviders } from '$lib/api/endpoints/auth';
|
||||||
@@ -86,7 +88,8 @@
|
|||||||
{#if passwordLoginEnabled}
|
{#if passwordLoginEnabled}
|
||||||
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
|
||||||
{/if}
|
{/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}}')}
|
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -81,6 +81,8 @@
|
|||||||
|
|
||||||
const groups = $derived.by(() => {
|
const groups = $derived.by(() => {
|
||||||
const out: Array<{ key: string; label: string; photos: PhotoItem[] }> = [];
|
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>();
|
const index = new Map<string, number>();
|
||||||
for (const p of items) {
|
for (const p of items) {
|
||||||
const d = new Date(photoTimestamp(p));
|
const d = new Date(photoTimestamp(p));
|
||||||
|
|||||||
@@ -840,7 +840,7 @@
|
|||||||
|
|
||||||
.info-value {
|
.info-value {
|
||||||
font-weight: var(--weight-medium, 500);
|
font-weight: var(--weight-medium, 500);
|
||||||
word-break: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.storage-stats {
|
.storage-stats {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||||
import { errorToast } from '$lib/utils/errors';
|
import { errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||||
import {
|
import {
|
||||||
addFavorite,
|
addFavorite,
|
||||||
@@ -143,7 +145,7 @@
|
|||||||
|
|
||||||
function open(entry: ResourceEntry) {
|
function open(entry: ResourceEntry) {
|
||||||
if (entry.kind === 'folder') {
|
if (entry.kind === 'folder') {
|
||||||
goto(`/files/${entry.id}`);
|
goto(resolve(`/files/${entry.id}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = byId.get(entry.id);
|
const item = byId.get(entry.id);
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
|
|
||||||
async function toggleFavorite(entry: ResourceEntry) {
|
async function toggleFavorite(entry: ResourceEntry) {
|
||||||
const isFav = favoriteIds.has(entry.id);
|
const isFav = favoriteIds.has(entry.id);
|
||||||
const next = new Set(favoriteIds);
|
const next = new SvelteSet(favoriteIds);
|
||||||
if (isFav) next.delete(entry.id);
|
if (isFav) next.delete(entry.id);
|
||||||
else next.add(entry.id);
|
else next.add(entry.id);
|
||||||
favoriteIds = next;
|
favoriteIds = next;
|
||||||
|
|||||||
@@ -274,7 +274,7 @@
|
|||||||
<div class="share__center">
|
<div class="share__center">
|
||||||
<Icon name="file" class="share__big-icon" />
|
<Icon name="file" class="share__big-icon" />
|
||||||
<h1>{meta?.item_name}</h1>
|
<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')}
|
{t('share.download', 'Download')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +307,7 @@
|
|||||||
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
onclick={() => setViewMode('list')}><Icon name="bars" /></button
|
||||||
>
|
>
|
||||||
</div>
|
</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" />
|
<Icon name="file-archive" />
|
||||||
{t('share.download_zip', 'Download ZIP')}
|
{t('share.download_zip', 'Download ZIP')}
|
||||||
</a>
|
</a>
|
||||||
@@ -367,7 +367,12 @@
|
|||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li>
|
<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__thumb"><Icon name="file" class="card__icon" /></span>
|
||||||
<span class="card__name">{f.name}</span>
|
<span class="card__name">{f.name}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import VirtualList from '$lib/components/VirtualList.svelte';
|
||||||
import { errorMessage } from '$lib/utils/errors';
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { searchFiles } from '$lib/api/endpoints/search';
|
import { searchFiles } from '$lib/api/endpoints/search';
|
||||||
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
import { fileInlineUrl } from '$lib/api/endpoints/files';
|
||||||
@@ -151,7 +153,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openFolder(folder: FolderItem) {
|
function openFolder(folder: FolderItem) {
|
||||||
goto(`/files/${folder.id}`);
|
goto(resolve(`/files/${folder.id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFile(file: FileItem) {
|
function openFile(file: FileItem) {
|
||||||
@@ -160,6 +162,18 @@
|
|||||||
|
|
||||||
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
|
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(() => {
|
$effect(() => {
|
||||||
// re-run when query, sort, scope, or any filter changes
|
// re-run when query, sort, scope, or any filter changes
|
||||||
void sortBy;
|
void sortBy;
|
||||||
@@ -256,43 +270,49 @@
|
|||||||
<div>{t('files.col_modified', 'Modified')}</div>
|
<div>{t('files.col_modified', 'Modified')}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each results.folders as folder (folder.id)}
|
<VirtualList
|
||||||
<div
|
items={entries}
|
||||||
class="file-item"
|
rowHeight={56}
|
||||||
role="button"
|
key={(e) => (e.kind === 'folder' ? e.folder.id : e.file.id)}
|
||||||
tabindex="0"
|
>
|
||||||
onclick={() => openFolder(folder)}
|
{#snippet row(e)}
|
||||||
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
|
{#if e.kind === 'folder'}
|
||||||
>
|
<div
|
||||||
<div class="name-cell">
|
class="file-item"
|
||||||
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
role="button"
|
||||||
<span>{folder.name}</span>
|
tabindex="0"
|
||||||
</div>
|
onclick={() => openFolder(e.folder)}
|
||||||
<div class="path-cell">{folder.path}</div>
|
onkeydown={(ev) => ev.key === 'Enter' && openFolder(e.folder)}
|
||||||
<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
|
|
||||||
>
|
>
|
||||||
<span>{file.name}</span>
|
<div class="name-cell">
|
||||||
</div>
|
<span class="file-icon file-icon--folder"><Icon name="folder" /></span>
|
||||||
<div class="path-cell">{file.path}</div>
|
<span>{e.folder.name}</span>
|
||||||
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
|
</div>
|
||||||
<div class="date-cell">{formatDate(file.modified_at)}</div>
|
<div class="path-cell">{e.folder.path}</div>
|
||||||
</div>
|
<div class="size-cell">—</div>
|
||||||
{/each}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { errorMessage } from '$lib/utils/errors';
|
import { errorMessage } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites';
|
import { dateBucket, resolveOwnerName, typeLabel } from '$lib/api/endpoints/favorites';
|
||||||
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
|
||||||
@@ -114,7 +115,7 @@
|
|||||||
|
|
||||||
function open(entry: ResourceEntry) {
|
function open(entry: ResourceEntry) {
|
||||||
if (entry.kind === 'folder') {
|
if (entry.kind === 'folder') {
|
||||||
goto(`/files/${entry.id}`);
|
goto(resolve(`/files/${entry.id}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const item = byId.get(entry.id);
|
const item = byId.get(entry.id);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import { errorMessage, errorToast } from '$lib/utils/errors';
|
import { errorMessage, errorToast } from '$lib/utils/errors';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
displayRole,
|
displayRole,
|
||||||
@@ -88,6 +89,8 @@
|
|||||||
|
|
||||||
const lanes = $derived.by((): Lane[] => {
|
const lanes = $derived.by((): Lane[] => {
|
||||||
const out: 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 byKey = new Map<string, Lane>();
|
||||||
const ensure = (key: string, header: Lane['header']): Lane => {
|
const ensure = (key: string, header: Lane['header']): Lane => {
|
||||||
let lane = byKey.get(key);
|
let lane = byKey.get(key);
|
||||||
@@ -179,7 +182,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openResource(item: OutgoingGrantItem) {
|
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');
|
else window.open(fileInlineUrl(item.resource.id), '_blank', 'noopener');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
{
|
{
|
||||||
"name": "OxiCloud",
|
"name": "OxiCloud",
|
||||||
"short_name": "OxiCloud",
|
"short_name": "OxiCloud",
|
||||||
"description": "Fast, private cloud storage built with Rust.",
|
"description": "Fast, private cloud storage built with Rust.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0f172a",
|
"background_color": "#0f172a",
|
||||||
"theme_color": "#0f172a",
|
"theme_color": "#0f172a",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/logo/logo-plain.svg",
|
"src": "/logo/logo-plain.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/logo/logo-maskable.svg",
|
"src": "/logo/logo-maskable.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/logo/maskable-192.png",
|
"src": "/logo/maskable-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/logo/maskable-512.png",
|
"src": "/logo/maskable-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/logo/maskable-512.png",
|
"src": "/logo/maskable-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user