feat: delta/instant upload + frontend UI/UX polish

Bundles the backend+frontend delta-upload (content-dedup) feature with a
batch of frontend fixes from this session.

Upload / dedup:
- Client-hashed delta & instant upload (deltaUpload, hashWasm vendor shim)
- Backend dedup batch endpoint (dedup_service, dedup_handler, routes)
- session store owned-hash helpers; unit tests + upload-strategy bench

Frontend UI/UX:
- Colour file-type icons in grid/list (per-type tinted tiles + glyph hue)
- Robust thumbnail fallback; PDFs now show their type icon (backend
  generates no PDF thumbnails) instead of a blank tile
- Fix PDF preview: load via a same-origin blob: iframe — the API URL is
  blocked by the global X-Frame-Options: DENY in the browser's framed
  PDF viewer, matching the existing CSP `frame-src blob:` design
- Groups: localized virtual-group description (no DB schema-note leak),
  add nav.groups to the 15 missing locales, fix primary-button contrast
- Repoint --color-text-light → --color-on-accent (was faint grey on accent)
- Nudge the admin role badge off the user-menu header divider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DioCrafts
2026-06-20 16:33:08 +02:00
parent f8490ad96e
commit d98e3117b2
35 changed files with 901 additions and 101 deletions
@@ -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'
});
});
});
@@ -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<DeltaUploadAnswer | null> {
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<Map<File, string>> {
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<File, string>();
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<string>;
try {
owned = await dedupCheckBatch([...new Set(hashByFile.values())]);
} catch {
return new Map();
}
const result = new Map<File, string>();
for (const [f, h] of hashByFile) if (owned.has(h)) result.set(f, h);
return result;
}
+40
View File
@@ -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<Set<string>> {
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<void> {
const form = new FormData();
if (folderId) form.append('folder_id', folderId);
+25
View File
@@ -22,6 +22,17 @@ const VIRTUAL_NAME_KEYS: Record<string, string> = {
[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<string, string> = {
[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
@@ -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'));
});
});
+35 -30
View File
@@ -40,9 +40,14 @@
let canEdit = $state(false);
/** Image zoom factor (1 = fit). */
let zoom = $state(1);
/** PDF embed fallback engaged when the <object> stays blank ~2s. */
let pdfFallback = $state(false);
let pdfObjectEl = $state<HTMLObjectElement | null>(null);
/** Object URL for the fetched PDF blob. The PDF is rendered from a same-origin
* blob: in an <iframe> (allowed by CSP `frame-src blob:`) rather than from its
* API URL directly — that response carries the global `X-Frame-Options: DENY`
* + `frame-ancestors 'none'`, which the browser's framed PDF viewer honours,
* so an embedded API URL renders as a broken-document icon. A blob has no
* response headers, so it sidesteps the framing block. */
let pdfUrl = $state<string | null>(null);
let pdfError = $state(false);
function isImage(f: FileItem): boolean {
const m = (f.mime_type ?? '').toLowerCase();
@@ -72,7 +77,7 @@
open = false;
textContent = '';
zoom = 1;
pdfFallback = false;
pdfError = false;
onrefresh?.();
}
@@ -94,7 +99,6 @@
const f = file;
canEdit = false;
zoom = 1;
pdfFallback = false;
const k = kindOf(f);
// Office docs (WOPI-editable, non-image) open straight in the editor
@@ -119,21 +123,25 @@
.finally(() => (textLoading = false));
}
// PDF blank-render guard: if the <object> shows nothing after ~2s,
// fall back to an <embed> (some browsers refuse <object> for PDFs).
// Fetch the PDF as a same-origin blob and view it via an object URL (see
// `pdfUrl`). The cleanup revokes the URL when the file changes or the
// viewer closes; a stale response (newer file opened mid-fetch) is dropped.
if (k === 'pdf') {
const timer = setTimeout(() => {
const el = pdfObjectEl;
let blank = false;
try {
const doc = el?.contentDocument;
blank = !doc || doc.body?.innerHTML === '';
} catch {
blank = false; // cross-origin: assume it rendered
}
if (blank) pdfFallback = true;
}, 2000);
return () => clearTimeout(timer);
pdfError = false;
pdfUrl = null;
let objectUrl: string | null = null;
apiFetch(fileInlineUrl(f.id), { credentials: 'same-origin' })
.then((r) => (r.ok ? r.blob() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((blob) => {
if (file !== f || !open) return;
objectUrl = URL.createObjectURL(blob);
pdfUrl = objectUrl;
})
.catch(() => (pdfError = true));
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
pdfUrl = null;
};
}
});
</script>
@@ -220,18 +228,15 @@
{:else if kind === 'audio'}
<audio class="fv__audio" src={fileInlineUrl(file.id)} controls></audio>
{:else if kind === 'pdf'}
{#if pdfFallback}
<embed class="fv__pdf" src={fileInlineUrl(file.id)} type="application/pdf" />
{:else}
<object
bind:this={pdfObjectEl}
class="fv__pdf"
data={fileInlineUrl(file.id)}
type="application/pdf"
title={file.name}
>
{#if pdfError}
<div class="fv__status fv__status--center">
<Icon name="file" class="fv__big-icon" />
<p>{t('files.preview_failed', 'Could not load preview.')}</p>
</object>
</div>
{:else if pdfUrl}
<iframe class="fv__pdf" src={pdfUrl} title={file.name}></iframe>
{:else}
<p class="fv__status">{t('common.loading', 'Loading…')}</p>
{/if}
{:else if kind === 'text'}
{#if textLoading}
@@ -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 @@
</script>
{#snippet row(entry: ResourceEntry)}
{@const iconName = entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="file-item"
@@ -323,8 +324,8 @@
</div>
{/if}
<div class="name-cell">
<span class="file-icon">
<Icon name={entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)} />
<span class="file-icon {fileIconKindClass(iconName)}">
<Icon name={iconName} />
</span>
<span class="name-cell__text">{entry.name}</span>
</div>
@@ -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);
});
});
+16
View File
@@ -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<void> {
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
+23 -1
View File
@@ -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.) */
@@ -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;
}
+1 -1
View File
@@ -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 {
+44
View File
@@ -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 '';
+46
View File
@@ -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<unknown>;
/** 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<HashWasmModule> | null = null;
function load(): Promise<HashWasmModule> {
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<string> {
const mod = await load();
const bytes = new Uint8Array(await file.arrayBuffer());
return mod.blake3Hex(bytes);
}