diff --git a/frontend/src/lib/api/endpoints/deltaUpload.test.ts b/frontend/src/lib/api/endpoints/deltaUpload.test.ts new file mode 100644 index 00000000..bd37fff4 --- /dev/null +++ b/frontend/src/lib/api/endpoints/deltaUpload.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// vi.mock is hoisted; build the spies with vi.hoisted so the factories can use them. +const { blake3Mock, byHashMock, batchMock } = vi.hoisted(() => ({ + blake3Mock: vi.fn(), + byHashMock: vi.fn(), + batchMock: vi.fn() +})); + +vi.mock('$lib/vendor/hashWasm', () => ({ blake3HexOfFile: blake3Mock })); +vi.mock('$lib/api/endpoints/files', () => ({ + createFileByHash: byHashMock, + dedupCheckBatch: batchMock +})); + +import { DELTA_UPLOAD_MIN_SIZE, instantUploadOwned, resolveOwnedHashes } from './deltaUpload'; + +const fakeFile = (size: number, name = 'x.bin') => ({ size, name }) as unknown as File; +const hashOf = (name: string) => name.padEnd(64, '0'); +const MB = 1024 * 1024; + +describe('resolveOwnedHashes (batch check)', () => { + beforeEach(() => { + blake3Mock.mockReset(); + batchMock.mockReset(); + blake3Mock.mockImplementation((f: File) => Promise.resolve(hashOf(f.name))); + }); + + it('hits nothing when no files are in-band (empty / >= delta threshold)', async () => { + const owned = await resolveOwnedHashes([ + fakeFile(0, 'empty'), + fakeFile(DELTA_UPLOAD_MIN_SIZE, 'big') + ]); + expect(owned.size).toBe(0); + expect(blake3Mock).not.toHaveBeenCalled(); + expect(batchMock).not.toHaveBeenCalled(); + }); + + it('hashes in-band files and maps only the server-owned subset in ONE batch call', async () => { + const a = fakeFile(2 * MB, 'a'); + const b = fakeFile(3 * MB, 'b'); + batchMock.mockResolvedValue(new Set([hashOf('a')])); // server owns only "a" + const owned = await resolveOwnedHashes([a, b]); + expect(batchMock).toHaveBeenCalledTimes(1); + expect(batchMock).toHaveBeenCalledWith([hashOf('a'), hashOf('b')]); + expect(owned.get(a)).toBe(hashOf('a')); + expect(owned.has(b)).toBe(false); + }); + + it('falls back to an empty map when client-side hashing fails', async () => { + blake3Mock.mockRejectedValue(new Error('wasm down')); + const owned = await resolveOwnedHashes([fakeFile(2 * MB, 'a')]); + expect(owned.size).toBe(0); + expect(batchMock).not.toHaveBeenCalled(); + }); + + it('falls back to an empty map when the batch request fails', async () => { + batchMock.mockRejectedValue(new Error('network')); + const owned = await resolveOwnedHashes([fakeFile(2 * MB, 'a')]); + expect(owned.size).toBe(0); + }); +}); + +describe('instantUploadOwned (zero-byte create)', () => { + beforeEach(() => byHashMock.mockReset()); + + it('reports zero-byte success on 201', async () => { + byHashMock.mockResolvedValue({ ok: true, status: 201, data: { id: 'f1' } }); + const r = await instantUploadOwned('folder', fakeFile(2 * MB, 'a'), hashOf('a')); + expect(r).toEqual({ ok: true, data: { id: 'f1' }, savedBytes: 2 * MB }); + expect(byHashMock).toHaveBeenCalledWith('folder', 'a', hashOf('a')); + }); + + it('falls back (null) when the blob vanished (404)', async () => { + byHashMock.mockResolvedValue({ ok: false, status: 404 }); + expect(await instantUploadOwned('folder', fakeFile(2 * MB), hashOf('a'))).toBeNull(); + }); + + it('surfaces a quota error on 507', async () => { + byHashMock.mockResolvedValue({ ok: false, status: 507 }); + expect(await instantUploadOwned('folder', fakeFile(2 * MB), hashOf('a'))).toEqual({ + ok: false, + isQuotaError: true, + errorMsg: 'Storage quota exceeded' + }); + }); +}); diff --git a/frontend/src/lib/api/endpoints/deltaUpload.ts b/frontend/src/lib/api/endpoints/deltaUpload.ts index 5250a90c..4290a443 100644 --- a/frontend/src/lib/api/endpoints/deltaUpload.ts +++ b/frontend/src/lib/api/endpoints/deltaUpload.ts @@ -8,6 +8,8 @@ * byte upload — delta is an optimization, never a gate. */ import { getCsrfToken } from '$lib/api/csrf'; +import { createFileByHash, dedupCheckBatch } from '$lib/api/endpoints/files'; +import { blake3HexOfFile } from '$lib/vendor/hashWasm'; /** Files smaller than this skip delta: the round-trips cost more than the bytes. */ export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024; @@ -128,3 +130,58 @@ export function tryDeltaUpload( worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' }); }); } + +/** + * Create a file from a blob the caller already owns (`POST /api/files/by-hash`) + * — zero content bytes cross the wire. `hash` must come from a prior batch + * ownership check ([`resolveOwnedHashes`]). Resolves an answer with + * `savedBytes = file.size` on success, surfaces a 507 quota error, or resolves + * `null` to fall back to a normal upload (e.g. the blob was GC'd between the + * check and this create — rare). + */ +export async function instantUploadOwned( + folderId: string, + file: File, + hash: string +): Promise { + const res = await createFileByHash(folderId, file.name, hash); + if (res.ok) return { ok: true, data: res.data, savedBytes: file.size }; + if (res.status === 507) { + return { ok: false, isQuotaError: true, errorMsg: 'Storage quota exceeded' }; + } + return null; +} + +/** + * Resolve which of `files` the server already owns, with a SINGLE batch round + * trip (the Dropbox-style "have you got these?" probe). Every file below the + * delta threshold is BLAKE3-hashed locally, the whole hash set is sent to + * `/api/dedup/check-batch`, and the owned subset is mapped back to `file → hash` + * so callers can instant-upload those (zero bytes) and upload the rest normally. + * + * Excludes empty files and files `>= DELTA_UPLOAD_MIN_SIZE` (the delta protocol + * dedups those itself). Resolves an empty map on any failure — hashing + * unavailable, request error — so uploads always proceed. + */ +export async function resolveOwnedHashes(files: File[]): Promise> { + const inBand = files.filter((f) => f.size > 0 && f.size < DELTA_UPLOAD_MIN_SIZE); + if (inBand.length === 0) return new Map(); + + const hashByFile = new Map(); + try { + for (const f of inBand) hashByFile.set(f, await blake3HexOfFile(f)); + } catch { + return new Map(); // WASM/hashing unavailable → skip instant uploads + } + + let owned: Set; + try { + owned = await dedupCheckBatch([...new Set(hashByFile.values())]); + } catch { + return new Map(); + } + + const result = new Map(); + for (const [f, h] of hashByFile) if (owned.has(h)) result.set(f, h); + return result; +} diff --git a/frontend/src/lib/api/endpoints/files.ts b/frontend/src/lib/api/endpoints/files.ts index 54444ac3..4583be11 100644 --- a/frontend/src/lib/api/endpoints/files.ts +++ b/frontend/src/lib/api/endpoints/files.ts @@ -4,6 +4,46 @@ import { getCsrfHeaders } from '$lib/api/csrf'; const JSON_HEADERS = { 'Content-Type': 'application/json' }; +/** + * Instant upload: materialise a file from a blob the caller **already owns**, + * by its whole-file BLAKE3 — zero content bytes cross the wire. Returns the HTTP + * status so the caller can fall back to a plain upload on 404 (hash not owned). + * Scoped to the caller's own content server-side (no cross-user probing). + */ +export async function createFileByHash( + folderId: string, + name: string, + hash: string +): Promise<{ ok: boolean; status: number; data?: unknown }> { + const res = await apiFetch('/api/files/by-hash', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ name, folder_id: folderId, hash }) + }); + const data = res.ok ? await res.json().catch(() => undefined) : undefined; + return { ok: res.ok, status: res.status, data }; +} + +/** + * Batch dedup check: given candidate whole-file BLAKE3 hashes, return the set + * the caller **already owns** — in a single round trip. Drives instant uploads: + * a file whose hash is in the set can be created with zero content bytes. + * Resolves an empty set on any failure, so the caller just uploads everything. + */ +export async function dedupCheckBatch(hashes: string[]): Promise> { + if (hashes.length === 0) return new Set(); + const res = await apiFetch('/api/dedup/check-batch', { + method: 'POST', + credentials: 'same-origin', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + body: JSON.stringify({ hashes }) + }); + if (!res.ok) return new Set(); + const data = (await res.json().catch(() => null)) as { owned?: string[] } | null; + return new Set(data?.owned ?? []); +} + export async function uploadFile(folderId: string | null, file: File): Promise { const form = new FormData(); if (folderId) form.append('folder_id', folderId); diff --git a/frontend/src/lib/api/endpoints/groups.ts b/frontend/src/lib/api/endpoints/groups.ts index cf7569d4..5d276853 100644 --- a/frontend/src/lib/api/endpoints/groups.ts +++ b/frontend/src/lib/api/endpoints/groups.ts @@ -22,6 +22,17 @@ const VIRTUAL_NAME_KEYS: Record = { [INTERNAL_GROUP_ID]: 'groups.virtual_internal_name' }; +/** + * Map of well-known virtual-group UUIDs → i18n key for a human-readable + * description. Virtual groups are server-seeded and their `description` column + * holds developer/schema notes (e.g. the Internal group's "…no rows in + * subject_group_members."), which must never reach end users — so virtual + * groups display a localized blurb instead of their raw `description`. + */ +const VIRTUAL_DESC_KEYS: Record = { + [INTERNAL_GROUP_ID]: 'groups.virtual_internal_explanation' +}; + export interface GroupItem { id: string; name: string; @@ -87,6 +98,20 @@ export function groupDisplayName(group: GroupItem): string { return group.name; } +/** + * Human-readable description for a group row. Virtual groups render a localized + * blurb (via the well-known UUID mapping) so their internal DB schema notes + * never leak to the UI; a virtual group without a mapped key shows nothing. + * User-defined groups display their raw `description` verbatim. + */ +export function groupDescription(group: GroupItem): string | null { + if (group.is_virtual) { + const key = VIRTUAL_DESC_KEYS[group.id]; + return key ? t(key, group.name) : null; + } + return group.description ?? null; +} + /** * Pick the icon registry name for a group avatar. Virtual (system-wide) * groups use `people-roof`; user-defined groups use `user-group`. Ported from diff --git a/frontend/src/lib/api/endpoints/uploadStrategies.bench.test.ts b/frontend/src/lib/api/endpoints/uploadStrategies.bench.test.ts new file mode 100644 index 00000000..0d8bc8f4 --- /dev/null +++ b/frontend/src/lib/api/endpoints/uploadStrategies.bench.test.ts @@ -0,0 +1,129 @@ +/** + * Benchmark: upload-dedup strategies compared. + * + * BASELINE — no instant upload: every file's bytes are sent. + * PER-FILE — instant upload probed one file at a time (a by-hash request per + * file; a miss costs an extra round trip before the plain upload). + * BATCH — Dropbox-style: hash every file, ONE `/api/dedup/check-batch` + * request, then instant-upload the owned ones and send the rest. + * + * It's an analytic model (round trips × RTT + bytes / bandwidth + hashing + * time), not a live transfer — the point is to compare the strategies' network + * cost. Run it to see the table: + * npm run test:unit -- uploadStrategies + */ +import { describe, expect, it } from 'vitest'; + +interface Cost { + roundTrips: number; + mbSent: number; + mbHashed: number; + seconds: number; +} +interface Scenario { + name: string; + files: number; + sizeMB: number; + ownedFrac: number; + rttMs: number; + mbps: number; +} + +/** BLAKE3 + file read throughput on a typical client (MB/s). */ +const HASH_MBPS = 1500; + +function cost(roundTrips: number, mbSent: number, mbHashed: number, s: Scenario): Cost { + const linkMBps = s.mbps / 8; + const seconds = roundTrips * (s.rttMs / 1000) + mbSent / linkMBps + mbHashed / HASH_MBPS; + return { roundTrips, mbSent, mbHashed, seconds }; +} + +function baseline(s: Scenario): Cost { + return cost(s.files, s.files * s.sizeMB, 0, s); +} +function perFile(s: Scenario): Cost { + const owned = Math.round(s.files * s.ownedFrac); + const miss = s.files - owned; + // owned → 1 by-hash create; miss → by-hash 404 + plain upload. Every file hashed. + return cost(owned + miss * 2, miss * s.sizeMB, s.files * s.sizeMB, s); +} +function batch(s: Scenario): Cost { + const owned = Math.round(s.files * s.ownedFrac); + const miss = s.files - owned; + // 1 batch check + owned creates + miss uploads. Every file hashed. + return cost(1 + owned + miss, miss * s.sizeMB, s.files * s.sizeMB, s); +} + +const SCENARIOS: Scenario[] = [ + { + name: '200×4MB · 50% re-upload · home (40ms/50Mbps)', + files: 200, + sizeMB: 4, + ownedFrac: 0.5, + rttMs: 40, + mbps: 50 + }, + { + name: '200×4MB · ALL new · home (40ms/50Mbps)', + files: 200, + sizeMB: 4, + ownedFrac: 0, + rttMs: 40, + mbps: 50 + }, + { + name: '200×4MB · ALL owned (re-sync) · home', + files: 200, + sizeMB: 4, + ownedFrac: 1, + rttMs: 40, + mbps: 50 + }, + { + name: '1000×0.5MB · 30% owned · WAN (80ms/100Mbps)', + files: 1000, + sizeMB: 0.5, + ownedFrac: 0.3, + rttMs: 80, + mbps: 100 + } +]; + +describe('upload-dedup strategies', () => { + it('batch never sends more bytes than baseline and matches per-file dedup', () => { + const rows: string[] = []; + rows.push(''); + rows.push('╔══ Upload-dedup strategies — analytic cost model ══════════════════════════'); + for (const s of SCENARIOS) { + const b = baseline(s); + const p = perFile(s); + const z = batch(s); + const line = (tag: string, c: Cost) => + `║ ${tag.padEnd(9)} │ RT ${String(c.roundTrips).padStart(4)} │ sent ${c.mbSent + .toFixed(0) + .padStart(4)} MB │ ~${c.seconds.toFixed(1).padStart(6)} s`; + rows.push(`╟─ ${s.name}`); + rows.push(line('baseline', b)); + rows.push(line('per-file', p)); + rows.push(line('BATCH', z)); + const vsBase = (1 - z.seconds / b.seconds) * 100; + const rtVsPerFile = p.roundTrips - z.roundTrips; + rows.push( + `║ → BATCH: ${vsBase.toFixed(0)}% faster than baseline · ${rtVsPerFile} fewer round trips than per-file` + ); + + // Invariants the strategies must satisfy: + expect(z.mbSent).toBe(p.mbSent); // batch and per-file dedup identically + expect(z.mbSent).toBeLessThanOrEqual(b.mbSent); // never worse than baseline on bytes + // When there's anything to dedup, batch is clearly faster than baseline. + // (With NOTHING owned, batch pays hashing + one check for no payoff — a + // small, honest overhead the table shows.) + if (s.ownedFrac > 0) expect(z.seconds).toBeLessThan(b.seconds); + // Batch collapses the N per-file probes into one check: for any miss it + // strictly wins on round trips (and is at most +1 in the all-owned case). + if (s.ownedFrac < 1) expect(z.roundTrips).toBeLessThan(p.roundTrips); + } + rows.push('╚═══════════════════════════════════════════════════════════════════════════'); + console.log(rows.join('\n')); + }); +}); diff --git a/frontend/src/lib/components/FileViewer.svelte b/frontend/src/lib/components/FileViewer.svelte index 1821e41b..33a304c4 100644 --- a/frontend/src/lib/components/FileViewer.svelte +++ b/frontend/src/lib/components/FileViewer.svelte @@ -40,9 +40,14 @@ let canEdit = $state(false); /** Image zoom factor (1 = fit). */ let zoom = $state(1); - /** PDF embed fallback engaged when the stays blank ~2s. */ - let pdfFallback = $state(false); - let pdfObjectEl = $state(null); + /** Object URL for the fetched PDF blob. The PDF is rendered from a same-origin + * blob: in an + {:else} +

{t('common.loading', 'Loading…')}

{/if} {:else if kind === 'text'} {#if textLoading} diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 5f4e3935..7bf192c4 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -59,7 +59,7 @@ import { t } from '$lib/i18n/index.svelte'; import { files as filesStore } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; - import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; import { gridColumns } from '$lib/utils/grid'; interface Props { @@ -301,6 +301,7 @@ {#snippet row(entry: ResourceEntry)} + {@const iconName = entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)}
{/if}
- - + + {entry.name}
diff --git a/frontend/src/lib/stores/session.svelte.test.ts b/frontend/src/lib/stores/session.svelte.test.ts new file mode 100644 index 00000000..b026ddf1 --- /dev/null +++ b/frontend/src/lib/stores/session.svelte.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { User } from '$lib/api/types'; + +// `vi.mock` is hoisted above imports, so the spy it references must be created +// with `vi.hoisted` (a plain top-level const isn't initialised yet when the +// factory runs). +const { fetchMeMock } = vi.hoisted(() => ({ fetchMeMock: vi.fn() })); + +vi.mock('$lib/api/endpoints/auth', () => ({ + fetchMe: () => fetchMeMock(), + tryRefresh: vi.fn(async () => false) +})); + +import { session } from './session.svelte'; + +const userWithUsage = (used: number) => ({ storage_used_bytes: used }) as unknown as User; + +describe('session.refresh', () => { + beforeEach(() => { + fetchMeMock.mockReset(); + session.reset(); + }); + + it('pulls the fresh storage usage into the reactive user (upload/delete sync)', async () => { + fetchMeMock.mockResolvedValue(userWithUsage(2048)); + await session.refresh(); + expect(session.user?.storage_used_bytes).toBe(2048); + }); + + it('leaves the current user intact when the probe returns null', async () => { + fetchMeMock.mockResolvedValue(userWithUsage(2048)); + await session.refresh(); + fetchMeMock.mockResolvedValue(null); + await session.refresh(); + expect(session.user?.storage_used_bytes).toBe(2048); + }); + + it('leaves the current user intact when the probe throws', async () => { + fetchMeMock.mockResolvedValue(userWithUsage(2048)); + await session.refresh(); + fetchMeMock.mockRejectedValue(new Error('network')); + await session.refresh(); + expect(session.user?.storage_used_bytes).toBe(2048); + }); +}); diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index d93d9b4c..645e8f22 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -40,6 +40,22 @@ class SessionStore { return this.user; } + /** + * Re-fetch the authenticated user from the server, bypassing the one-shot + * `load()` cache. Call after operations that change server-side user state — + * chiefly storage usage after uploads / deletes — so the UI reflects the new + * `storage_used_bytes` instead of the value cached at login. A transient + * failure leaves the current user untouched (never logs the UI out). + */ + async refresh(): Promise { + try { + const me = await fetchMe(); + if (me) this.user = me; + } catch { + /* keep the existing user on a transient /api/auth/me failure */ + } + } + /** * Resolve the caller's default personal drive's root folder — the landing * point for `/files` and the `/` redirect. Externals (grant-only) have no diff --git a/frontend/src/lib/styles/base/variables.css b/frontend/src/lib/styles/base/variables.css index 82efd220..5a850f6d 100644 --- a/frontend/src/lib/styles/base/variables.css +++ b/frontend/src/lib/styles/base/variables.css @@ -236,7 +236,11 @@ --color-text-gray: var(--color-text-muted); --color-text-medium: var(--color-text-muted); --color-text-faint2: var(--color-text-faint); - --color-text-light: var(--color-text-faint); + /* "Light" = light-coloured (near-white) text for accent/dark fills, NOT a + * faint tier. Every consumer is a primary button (background: --color-primary), + * so this must resolve to the on-accent foreground, not muted grey — which + * rendered muddy and failed contrast on the orange accent. */ + --color-text-light: var(--color-on-accent); --color-text-placeholder: var(--color-text-faint); /* Accent (orange) — mostly mode-agnostic. */ @@ -644,6 +648,24 @@ /* Status badge — gray/disabled */ --color-badge-gray: #d1d5db; + /* File-type glyph colours — one vivid hue per broad file family, used by the + * .file-icon--* buckets (resourceList.css) so the grid/list type icons read + * as colourful tiles instead of flat monochrome. Tile tints are derived from + * these with color-mix(), so only the foreground hue lives here. Dark-mode + * shades are lightened for contrast against the dark tile fill. */ + --file-kind-folder: light-dark(#3b82f6, #60a5fa); + --file-kind-pdf: light-dark(#ef4444, #f87171); + --file-kind-doc: light-dark(#2563eb, #60a5fa); + --file-kind-sheet: light-dark(#16a34a, #4ade80); + --file-kind-slides: light-dark(#ea580c, #fb923c); + --file-kind-archive: light-dark(#d97706, #fbbf24); + --file-kind-code: light-dark(#7c3aed, #a78bfa); + --file-kind-image: light-dark(#0891b2, #22d3ee); + --file-kind-video: light-dark(#c026d3, #e879f9); + --file-kind-audio: light-dark(#db2777, #f472b6); + --file-kind-text: light-dark(#475569, #94a3b8); + --file-kind-generic: light-dark(#64748b, #94a3b8); + /* (Removed: the legacy dark-mode badge tokens that lived here had zero * consumers — the light-dark() badge tokens above are the single source.) */ diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 8418f4f8..f07f0800 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -43,6 +43,69 @@ pointer-events: none; } +/* ── File-type colours ─────────────────────────────────────── + Each bucket sets a local --fk (the foreground hue, from the + --file-kind-* tokens). The glyph takes --fk directly and the tile + fill/ring derive soft tints from it with color-mix(), so the icons read + as colourful tiles instead of flat monochrome. Adding a bucket is a + single custom-property line; the consuming rules never change. */ +.file-icon { + --fk: var(--file-kind-generic); +} + +.file-icon--folder { + --fk: var(--file-kind-folder); +} + +.file-icon--pdf { + --fk: var(--file-kind-pdf); +} + +.file-icon--doc { + --fk: var(--file-kind-doc); +} + +.file-icon--sheet { + --fk: var(--file-kind-sheet); +} + +.file-icon--slides { + --fk: var(--file-kind-slides); +} + +.file-icon--archive { + --fk: var(--file-kind-archive); +} + +.file-icon--code { + --fk: var(--file-kind-code); +} + +.file-icon--image { + --fk: var(--file-kind-image); +} + +.file-icon--video { + --fk: var(--file-kind-video); +} + +.file-icon--audio { + --fk: var(--file-kind-audio); +} + +.file-icon--text { + --fk: var(--file-kind-text); +} + +.file-icon--generic { + --fk: var(--file-kind-generic); +} + +.file-icon > i, +.file-icon > svg { + color: var(--fk); +} + /* ── Item states ─────────────────────────────────────────── */ .file-item { @@ -355,9 +418,10 @@ align-items: center; justify-content: center; border-radius: var(--radius-lg); - /* Hairline inner ring — consistent with the grid thumbnails so light - previews don't bleed into the row. */ - box-shadow: inset 0 0 0 1px var(--color-border); + /* Soft type-tinted tile + matching hairline ring so the row glyph reads as + a colourful chip (and light previews don't bleed into the row). */ + background: color-mix(in srgb, var(--fk) 12%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fk) 22%, var(--color-border)); font-size: var(--text-md); margin-bottom: 0; flex-shrink: 0; @@ -859,9 +923,11 @@ height: auto; aspect-ratio: 4 / 3; border-radius: var(--radius-lg); - background: var(--color-bg-input); - /* Hairline inner ring so light thumbnails don't bleed into the card. */ - box-shadow: inset 0 0 0 1px var(--color-border); + /* Soft type-tinted fill + matching hairline ring (a thumbnail, when + present, covers both edge-to-edge). Gives non-preview files a modern + colourful tile keyed to their type instead of a flat grey panel. */ + background: color-mix(in srgb, var(--fk) 9%, var(--color-bg-input)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--fk) 22%, var(--color-border)); margin: 0 0 var(--space-3); font-size: 30px; } diff --git a/frontend/src/lib/styles/ported/userMenu.css b/frontend/src/lib/styles/ported/userMenu.css index edef1118..dd2a5987 100644 --- a/frontend/src/lib/styles/ported/userMenu.css +++ b/frontend/src/lib/styles/ported/userMenu.css @@ -262,7 +262,7 @@ } .user-menu-role-badge { - padding: 0 var(--space-5) var(--space-1); + padding: var(--space-2) var(--space-5) var(--space-1); } .role-badge { diff --git a/frontend/src/lib/utils/display.ts b/frontend/src/lib/utils/display.ts index 8c6b6b2a..17dde94e 100644 --- a/frontend/src/lib/utils/display.ts +++ b/frontend/src/lib/utils/display.ts @@ -12,6 +12,50 @@ export function iconNameFromClass(iconClass: string | undefined | null): string return token ? token.slice(3) : 'file'; } +/** + * Coarse colour bucket for a resolved icon name (see {@link iconNameFromClass}). + * One hue per broad file family so the grid/list glyphs render in type-specific + * colours instead of a flat monochrome. Each bucket is backed by a + * `--file-kind-*` token (variables.css) and consumed via the `.file-icon--*` + * modifier classes (resourceList.css). + */ +export function fileIconKind(iconName: string): string { + switch (iconName) { + case 'folder': + case 'folder-open': + return 'folder'; + case 'file-pdf': + return 'pdf'; + case 'file-word': + return 'doc'; + case 'file-excel': + return 'sheet'; + case 'file-powerpoint': + return 'slides'; + case 'file-archive': + case 'file-zipper': + return 'archive'; + case 'file-code': + return 'code'; + case 'file-image': + return 'image'; + case 'file-video': + return 'video'; + case 'file-audio': + return 'audio'; + case 'file-alt': + case 'file-lines': + return 'text'; + default: + return 'generic'; + } +} + +/** `.file-icon` colour-bucket modifier class for a resolved icon name. */ +export function fileIconKindClass(iconName: string): string { + return `file-icon--${fileIconKind(iconName)}`; +} + /** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */ export function formatDate(value: number | string | null | undefined): string { if (value === null || value === undefined) return ''; diff --git a/frontend/src/lib/vendor/hashWasm.ts b/frontend/src/lib/vendor/hashWasm.ts new file mode 100644 index 00000000..1e7748fb --- /dev/null +++ b/frontend/src/lib/vendor/hashWasm.ts @@ -0,0 +1,46 @@ +/** + * Lazy loader + minimal typing for the vendored OxiCloud hash WASM + * (`/vendors/hash-wasm/oxicloud_hash_wasm.js`) — the same BLAKE3 crate the + * server and the delta worker use. Loaded on the main thread to compute a + * small file's whole-file BLAKE3 for instant ("by-hash") upload; large files + * hash off-thread inside the delta worker instead. + */ + +interface HashWasmModule { + /** wasm-bindgen init; resolves once the `.wasm` is instantiated. */ + default: () => Promise; + /** One-shot BLAKE3 of a buffer → 64-char lowercase hex. */ + blake3Hex: (data: Uint8Array) => string; +} + +const WASM_GLUE_URL = '/vendors/hash-wasm/oxicloud_hash_wasm.js'; + +let modPromise: Promise | null = null; + +function load(): Promise { + if (!modPromise) { + modPromise = (async () => { + // Runtime URL of a vendored asset (not a project module) — keep Vite + // from trying to resolve/bundle it, exactly like the delta worker does. + const mod = (await import(/* @vite-ignore */ WASM_GLUE_URL)) as unknown as HashWasmModule; + await mod.default(); + return mod; + })().catch((err) => { + modPromise = null; // let a later call retry after a transient failure + throw err; + }); + } + return modPromise; +} + +/** + * Whole-file BLAKE3 (64-char lowercase hex) of `file`. Matches the server's + * `file_hash` (BLAKE3 over the whole content), so it can be handed to + * `/api/files/by-hash`. Reads the file fully into memory — intended for small + * files only. + */ +export async function blake3HexOfFile(file: File): Promise { + const mod = await load(); + const bytes = new Uint8Array(await file.arrayBuffer()); + return mod.blake3Hex(bytes); +} diff --git a/frontend/src/routes/files/[...path]/+page.svelte b/frontend/src/routes/files/[...path]/+page.svelte index 52165d24..107b8ec1 100644 --- a/frontend/src/routes/files/[...path]/+page.svelte +++ b/frontend/src/routes/files/[...path]/+page.svelte @@ -26,11 +26,14 @@ fileThumbnailUrl, moveFile, renameFile, - uploadFile, uploadFileWithProgress } from '$lib/api/endpoints/files'; import { folderZipUrl } from '$lib/api/endpoints/folders'; - import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload'; + import { + instantUploadOwned, + resolveOwnedHashes, + tryDeltaUpload + } from '$lib/api/endpoints/deltaUpload'; import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites'; import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi'; import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music'; @@ -56,7 +59,7 @@ typeLabel } from '$lib/stores/files.svelte'; import { formatBytes } from '$lib/utils/format'; - import { formatDate, iconNameFromClass } from '$lib/utils/display'; + import { formatDate, iconNameFromClass, fileIconKindClass } from '$lib/utils/display'; import { gridColumns } from '$lib/utils/grid'; // File preview and the WOPI editor are heavy and only appear on demand, so @@ -286,6 +289,39 @@ } } + /** + * Upload one file through the best available path, returning the bytes saved + * by deduplication (0 when the body was sent in full). Order: + * 1. Instant by-hash upload — zero bytes when `ownedHash` is set (the batch + * check found the server already has this exact blob). + * 2. Delta upload — sub-file CDC dedup for large files (>= 8 MB). + * 3. Plain byte upload — fallback when neither applies. + * Throws on a hard failure (e.g. quota). + */ + async function uploadOneFile( + folderId: string | null, + file: File, + report: (frac: number) => void, + ownedHash: string | null + ): Promise { + const dedup = + (ownedHash && folderId ? await instantUploadOwned(folderId, file, ownedHash) : null) ?? + (await tryDeltaUpload(file, folderId, (pct) => report(pct / 100))); + if (dedup) { + if (!dedup.ok) throw new Error(dedup.errorMsg ?? 'upload failed'); + return dedup.savedBytes ?? 0; + } + await uploadFileWithProgress(folderId, file, report); + return 0; + } + + /** Final bell message for a finished upload, noting deduplicated bytes. */ + function uploadDoneMessage(savedBytes: number): string { + if (savedBytes <= 0) return t('files.uploaded', 'Upload complete'); + const mb = (savedBytes / (1024 * 1024)).toFixed(1); + return t('files.uploaded_saved', { mb }, `Upload complete — ${mb} MB deduplicated`); + } + /** * Upload a batch of files into the current folder, reporting aggregate * progress through a single bell notification with a progress bar. @@ -301,32 +337,23 @@ const nid = ui.startProgress(label(0)); let savedBytes = 0; try { + // One batch round trip: which of these files does the server already + // have? Owned ones upload as zero bytes; the rest go delta/plain. + const owned = await resolveOwnedHashes(files); for (let i = 0; i < files.length; i++) { const report = (frac: number) => { const base = i / total; const step = Number.isNaN(frac) ? 0 : frac / total; ui.updateProgress(nid, Math.round((base + step) * 100), label(i)); }; - // Delta upload for large files (dedup); transparently falls back. - const delta = await tryDeltaUpload(files[i], currentId, (pct) => report(pct / 100)); - if (delta) { - if (!delta.ok) throw new Error(delta.errorMsg ?? 'upload failed'); - savedBytes += delta.savedBytes ?? 0; - } else { - await uploadFileWithProgress(currentId, files[i], report); - } + savedBytes += await uploadOneFile(currentId, files[i], report, owned.get(files[i]) ?? null); ui.updateProgress(nid, Math.round(((i + 1) / total) * 100), label(i + 1)); } - const done = - savedBytes > 0 - ? t( - 'files.uploaded_saved', - { mb: (savedBytes / (1024 * 1024)).toFixed(1) }, - `Upload complete — ${(savedBytes / (1024 * 1024)).toFixed(1)} MB deduplicated` - ) - : t('files.uploaded', 'Upload complete'); - ui.finishProgress(nid, done, 'success'); + ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success'); await reload(); + // Storage usage changed server-side — pull the fresh figure so the + // "Almacenamiento" bar moves off its login value instead of 0%. + void session.refresh(); } catch (err) { ui.finishProgress(nid, errorMessage(err), 'error'); } finally { @@ -430,6 +457,7 @@ if (kind === 'file') await deleteFile(id); else await deleteFolder(id); await reload(); + void session.refresh(); } catch (e) { errorToast(e); } @@ -480,16 +508,16 @@ }); /** - * Whether the server can render a thumbnail preview for this file. Images and - * videos always have one; PDFs (and other thumbnail-capable docs) do too, so - * surface those rather than a generic icon. A failed load falls back to - * the icon via onerror, so being permissive here is safe. + * Whether the server can render a thumbnail preview for this file. Only images + * and videos have server-side thumbnails (see `ThumbnailService::is_supported_image` + * plus client-uploaded video frames); the backend does NOT rasterise PDFs or + * documents, so claiming it could left their tiles blank (the doomed + * 404s and `onerror` hides it). Non-thumbnail files fall back to their colour + * type icon, which renders underneath the regardless. */ function canThumbnail(file: FileItem): boolean { const m = file.mime_type ?? ''; - if (m.startsWith('image/') || m.startsWith('video/')) return true; - if (m === 'application/pdf') return true; - return /\.pdf$/i.test(file.name); + return m.startsWith('image/') || m.startsWith('video/'); } // ── Multi-select + batch ──────────────────────────────────────────────── @@ -690,6 +718,7 @@ } clearSelection(); await reload(); + void session.refresh(); } // ── Drag-to-move ───────────────────────────────────────────────────────── @@ -955,6 +984,13 @@ async function uploadTree(entries: { file: File; relativePath: string }[]) { if (entries.length === 0) return; uploading = true; + const total = entries.length; + const label = (done: number) => + t('files.uploading_n', { done, total }, `Uploading ${done}/${total} files…`); + // Same bell progress notification as uploadBatch, so folder uploads show + // live progress + a final result instead of staying silent until the end. + const nid = ui.startProgress(label(0)); + let savedBytes = 0; try { // Map each relative directory path to its created folder id; '' = current. const dirIds = new Map([['', currentId]]); @@ -969,16 +1005,31 @@ return created.id; } - for (const { file, relativePath } of entries) { + // One batch round trip for the whole tree: which files does the server + // already have? Owned ones upload as zero bytes. + const owned = await resolveOwnedHashes(entries.map((e) => e.file)); + for (let i = 0; i < entries.length; i++) { + const { file, relativePath } = entries[i]; const segs = relativePath.split('/'); segs.pop(); // drop the filename, keep the directory trail const dirId = await ensureDir(segs.join('/')); - await uploadFile(dirId, file); + savedBytes += await uploadOneFile( + dirId, + file, + (frac) => { + const base = i / total; + const step = Number.isNaN(frac) ? 0 : frac / total; + ui.updateProgress(nid, Math.round((base + step) * 100), label(i)); + }, + owned.get(file) ?? null + ); + ui.updateProgress(nid, Math.round(((i + 1) / total) * 100), label(i + 1)); } - ui.notify(t('files.uploaded', 'Upload complete'), 'success'); + ui.finishProgress(nid, uploadDoneMessage(savedBytes), 'success'); await reload(); + void session.refresh(); } catch (err) { - errorToast(err); + ui.finishProgress(nid, errorMessage(err), 'error'); } finally { uploading = false; } @@ -1458,7 +1509,7 @@ />
-
+
{folder.name} {#if favoriteIds.has(folder.id)}
-
+
+ + {#if canThumbnail(file)} ((e.currentTarget as HTMLImageElement).style.display = 'none')} /> - {:else} - {/if}
{file.name} diff --git a/frontend/src/routes/groups/+page.svelte b/frontend/src/routes/groups/+page.svelte index 3cc8f623..d943c104 100644 --- a/frontend/src/routes/groups/+page.svelte +++ b/frontend/src/routes/groups/+page.svelte @@ -6,6 +6,7 @@ addUserMember, createGroup, deleteGroup, + groupDescription, groupDisplayName, groupIconName, INTERNAL_GROUP_ID, @@ -243,6 +244,7 @@ {:else}
    {#each groups as g (g.id)} + {@const description = groupDescription(g)}