visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Typed API client with transparent 401 → token-refresh → retry.
*
* Ported from static/js/core/fetchWrapper.js. Unlike the legacy version this
* does NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
* Ported from static/js/core/fetchWrapper.js. Unlike that wrapper, this does
* NOT monkeypatch `window.fetch`; every endpoint module calls `apiFetch`
* explicitly. The behavioural invariants are preserved exactly:
*
* - A captured raw `fetch` is used for the real network calls so the refresh
+282 -4
View File
@@ -1,7 +1,7 @@
/**
* Admin endpoints — ported from views/admin/admin.js. Covers users + plugins
* (the core management surfaces). Settings (OIDC/storage/SMTP), storage
* migration, and plugin logs/retention are not yet ported — see the admin route.
* Admin endpoints — ported from views/admin/admin.js. Covers users, plugins
* (incl. logs/retention/live SSE tail), dashboard, settings (OIDC/storage/SMTP),
* and storage migration (incl. the verify integrity check).
*/
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
@@ -38,7 +38,8 @@ export function listUsers(limit: number, offset: number): Promise<AdminUsersPage
export interface CreateUserInput {
username: string;
password: string;
email: string;
/** Optional — the backend auto-generates an address when null/empty. */
email: string | null;
role: string;
quota_bytes: number;
}
@@ -67,6 +68,207 @@ export function deleteUser(userId: string): Promise<void> {
return mutate(`/api/admin/users/${userId}`, 'DELETE');
}
// ── Dashboard ───────────────────────────────────────────────────────────
export interface AdminDashboard {
total_users: number;
active_users: number;
admin_users: number;
server_version: string;
total_used_bytes: number;
total_quota_bytes: number;
storage_usage_percent: number;
auth_enabled: boolean;
oidc_configured: boolean;
quotas_enabled: boolean;
registration_enabled?: boolean;
users_over_80_percent: number;
users_over_quota: number;
}
export function getDashboard(): Promise<AdminDashboard> {
return apiJson<AdminDashboard>('/api/admin/dashboard', { credentials: 'same-origin' });
}
export function setRegistrationEnabled(enabled: boolean): Promise<void> {
return mutate('/api/admin/settings/registration', 'PUT', { registration_enabled: enabled });
}
// ── SMTP ────────────────────────────────────────────────────────────────
export interface SmtpInfo {
enabled: boolean;
host: string;
port: number;
tls: string;
from: string;
user_state: string;
}
export function getSmtpInfo(): Promise<SmtpInfo> {
return apiJson<SmtpInfo>('/api/admin/smtp/info', { credentials: 'same-origin' });
}
export interface SmtpTestResult {
success: boolean;
code?: string | number;
message?: string;
error?: string;
}
/** Result of POST .../settings/storage/test — the S3 connection probe. */
export interface StorageTestResult {
connected?: boolean;
success?: boolean;
backend_type?: string;
available_bytes?: number | null;
message?: string;
}
export async function sendSmtpTest(to: string): Promise<SmtpTestResult> {
const res = await apiFetch('/api/admin/smtp/test', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ to })
});
if (res.status === 503)
return { success: false, message: 'SMTP is not configured on this server.' };
return (await res.json().catch(() => ({ success: false }))) as SmtpTestResult;
}
// ── OIDC settings ─────────────────────────────────────────────────────────
export interface OidcSettings {
enabled: boolean;
issuer_url: string;
client_id: string;
scopes: string | null;
auto_provision: boolean;
admin_groups: string | null;
disable_password_login: boolean;
provider_name: string | null;
callback_url?: string;
client_secret_set?: boolean;
env_overrides?: string[];
}
export interface OidcTestResult {
success: boolean;
message: string;
issuer?: string;
authorization_endpoint?: string;
provider_name_suggestion?: string;
}
export function getOidcSettings(): Promise<OidcSettings> {
return apiJson<OidcSettings>('/api/admin/settings/oidc', { credentials: 'same-origin' });
}
export async function testOidc(issuerUrl: string): Promise<OidcTestResult> {
const res = await apiFetch('/api/admin/settings/oidc/test', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ issuer_url: issuerUrl })
});
return (await res
.json()
.catch(() => ({ success: false, message: 'Request failed' }))) as OidcTestResult;
}
export function saveOidc(body: Record<string, unknown>): Promise<void> {
return mutate('/api/admin/settings/oidc', 'PUT', body);
}
// ── Storage settings + migration ───────────────────────────────────────────
export interface StorageSettings {
backend: string;
s3_endpoint_url?: string | null;
s3_bucket?: string | null;
s3_region?: string | null;
s3_access_key_set?: boolean;
s3_secret_key_set?: boolean;
s3_force_path_style?: boolean;
env_overrides?: string[];
current_backend?: string;
total_blobs?: number;
total_bytes_stored?: number;
dedup_ratio?: number;
}
export function getStorageSettings(): Promise<StorageSettings> {
return apiJson<StorageSettings>('/api/admin/settings/storage', { credentials: 'same-origin' });
}
export function saveStorage(body: Record<string, unknown>): Promise<void> {
return mutate('/api/admin/settings/storage', 'PUT', body);
}
export async function testStorage(body: Record<string, unknown>): Promise<StorageTestResult> {
const res = await apiFetch('/api/admin/settings/storage/test', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
return (await res.json().catch(() => ({ connected: false }))) as StorageTestResult;
}
export interface MigrationStatus {
status: 'idle' | 'running' | 'paused' | 'completed' | 'failed';
total_blobs: number;
migrated_blobs: number;
migrated_bytes: number;
throughput_bytes_per_sec?: number;
failed_blobs?: string[];
}
export function getMigration(): Promise<MigrationStatus> {
return apiJson<MigrationStatus>('/api/admin/storage/migration', { credentials: 'same-origin' });
}
export function migrationAction(action: 'start' | 'pause' | 'resume' | 'complete'): Promise<void> {
const body = action === 'start' ? { concurrency: 4 } : {};
return mutate(`/api/admin/storage/migration/${action}`, 'POST', body);
}
/** Result of a `verify` integrity check (POST .../migration/verify). */
export interface MigrationVerifyResult {
passed: boolean;
sample_checked: number;
pg_blob_count: number;
missing_in_target: string[];
size_mismatches: string[];
}
/**
* Run an integrity verification pass over a sample of migrated blobs. Unlike
* the other migration actions this returns a structured result that the caller
* renders (passed / sample-checked / missing / size-mismatch counts).
*/
export async function verifyMigration(sampleSize = 100): Promise<MigrationVerifyResult> {
const res = await apiFetch('/api/admin/storage/migration/verify', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ sample_size: sampleSize })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(e.message || `verify failed: ${res.status}`);
}
const r = (await res.json()) as Partial<MigrationVerifyResult>;
return {
passed: r.passed ?? false,
sample_checked: r.sample_checked ?? 0,
pg_blob_count: r.pg_blob_count ?? 0,
missing_in_target: r.missing_in_target ?? [],
size_mismatches: r.size_mismatches ?? []
};
}
// ── Plugins ─────────────────────────────────────────────────────────────
export interface PluginInfo {
@@ -75,6 +277,49 @@ export interface PluginInfo {
version?: string;
enabled: boolean;
description?: string;
abi?: string | number;
subscriptions?: string[];
}
export interface PluginRetention {
retention_days: number;
max_bytes: number;
}
/**
* Install a plugin from a .zip bundle. The browser sets the multipart
* Content-Type (with boundary) — do not override it here.
*/
export async function installPlugin(bundle: File): Promise<PluginInfo> {
const form = new FormData();
form.append('bundle', bundle);
const res = await apiFetch('/api/admin/plugins', {
method: 'POST',
credentials: 'same-origin',
headers: { ...getCsrfHeaders() },
body: form
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(e.message || `install failed: ${res.status}`);
}
return (await res.json()) as PluginInfo;
}
export async function getPluginRetention(id: string): Promise<PluginRetention | null> {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, {
credentials: 'same-origin'
});
if (!res.ok) return null;
return (await res.json()) as PluginRetention;
}
export function savePluginRetention(id: string, r: PluginRetention): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/retention`, 'PUT', r);
}
export function clearPluginLogs(id: string): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/logs`, 'DELETE');
}
export interface PluginsResult {
@@ -99,3 +344,36 @@ export function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
export function deletePlugin(id: string): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}`, 'DELETE');
}
export interface PluginLogEntry {
timestamp?: string;
ts?: string;
level?: string;
message?: string;
/** Streamed-entry message field (SSE / persisted logs use `msg`). */
msg?: string;
/** "outcome" | "log" — outcome entries carry a `reason`. */
kind?: string;
reason?: string;
invocation_id?: string;
[k: string]: unknown;
}
export interface PluginLogPage {
total: number;
entries: PluginLogEntry[];
}
export function getPluginLogs(
id: string,
opts: { limit?: number; offset?: number; level?: string; search?: string } = {}
): Promise<PluginLogPage> {
const params = new URLSearchParams();
params.set('limit', String(opts.limit ?? 50));
params.set('offset', String(opts.offset ?? 0));
if (opts.level) params.set('level', opts.level);
if (opts.search) params.set('search', opts.search);
return apiJson<PluginLogPage>(`/api/admin/plugins/${encodeURIComponent(id)}/logs?${params}`, {
credentials: 'same-origin'
});
}
+121
View File
@@ -52,6 +52,127 @@ export async function login(emailOrUsername: string, password: string): Promise<
return (await res.json()) as AuthResponse;
}
export interface OidcProviders {
enabled: boolean;
provider_name?: string;
password_login_enabled?: boolean;
authorize_endpoint?: string;
}
/** Public OIDC provider info for the login page. */
export async function getOidcProviders(): Promise<OidcProviders> {
try {
const res = await fetch('/api/auth/oidc/providers');
if (!res.ok) return { enabled: false };
return (await res.json()) as OidcProviders;
} catch {
return { enabled: false };
}
}
export interface AuthStatus {
initialized: boolean;
admin_count: number;
registration_allowed: boolean;
}
/**
* System bootstrap probe. When `initialized === false` no admin exists yet and
* the login page must offer the first-run admin-setup flow. Raw `fetch` (NOT
* apiFetch): this is unauthenticated and a non-2xx must not bounce through the
* refresh interceptor. Defaults to "initialized" on any failure so a transient
* error never strands operators on the setup wizard.
*/
export async function getAuthStatus(): Promise<AuthStatus> {
try {
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
if (!res.ok) return { initialized: true, admin_count: 1, registration_allowed: true };
return (await res.json()) as AuthStatus;
} catch {
return { initialized: true, admin_count: 1, registration_allowed: true };
}
}
/**
* First-run admin bootstrap. POSTs to `/api/setup`, which creates the admin
* user and marks the system initialized. Raw `fetch` (NOT apiFetch) so a 401
* surfaces as a genuine failure instead of triggering the refresh-and-redirect.
*/
export async function setupAdmin(email: string, password: string): Promise<void> {
const res = await fetch('/api/setup', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: 'admin', email, password })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `setup failed: ${res.status}`);
}
}
/**
* OIDC code-exchange fallback. When the IdP round-trip lands back on the login
* page with `?oidc_code=`, exchange it for a session (cookies are set
* server-side). Raw `fetch` (NOT apiFetch) — a 401 here is a genuine exchange
* failure, not an expired access token. Returns the user on success, null on
* any failure so the caller can fall through to the normal login UI.
*/
export async function exchangeOidcCode(code: string): Promise<User | null> {
try {
const res = await fetch('/api/auth/oidc/exchange', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ code })
});
if (!res.ok) return null;
const data = (await res.json()) as { user?: User };
return data.user ?? null;
} catch {
return null;
}
}
/**
* Register a new user. Raw `fetch` (NOT apiFetch) so a 401/validation failure
* surfaces to the caller instead of tripping the global refresh-and-redirect
* interceptor — mirrors the login primitive.
*/
export async function register(username: string, email: string, password: string): Promise<void> {
const res = await fetch('/api/auth/register', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username, email, password, role: 'user' })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `register failed: ${res.status}`);
}
}
export type MagicLinkResult = 'sent' | 'unavailable';
/**
* Anti-enumeration sign-in by email. Any 2xx resolves to `sent` with a uniform
* message regardless of whether the email maps to an account. 503 means SMTP
* isn't configured (`unavailable`) — operators need to see that. Other non-2xx
* throw so the caller can show a generic error. Raw `fetch` (NOT apiFetch):
* unauthenticated, must not enter the refresh interceptor.
*/
export async function sendMagicLink(email: string): Promise<MagicLinkResult> {
const res = await fetch('/api/auth/magic-link/send', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ email })
});
if (res.status === 503) return 'unavailable';
if (!res.ok) throw new Error(`magic-link failed: ${res.status}`);
return 'sent';
}
export async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', {
method: 'POST',
+35
View File
@@ -0,0 +1,35 @@
/**
* Batch operations (/api/batch/*). Used for multi-item copy — move and delete
* already have per-item endpoints the files view loops over, but copy only
* exists as a batch endpoint on the backend.
*/
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function post(url: string, body: unknown): Promise<void> {
const res = await apiFetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `${url} failed: ${res.status}`);
}
}
export function copyFiles(fileIds: string[], targetFolderId: string | null): Promise<void> {
if (fileIds.length === 0) return Promise.resolve();
return post('/api/batch/files/copy', { file_ids: fileIds, target_folder_id: targetFolderId });
}
export function copyFolders(folderIds: string[], targetFolderId: string | null): Promise<void> {
if (folderIds.length === 0) return Promise.resolve();
return post('/api/batch/folders/copy', {
folder_ids: folderIds,
target_folder_id: targetFolderId
});
}
@@ -0,0 +1,130 @@
/**
* Delta upload ("upload only what changed") — ported from
* features/files/deltaUpload.js. Main-thread orchestrator for
* `/static/workers/deltaWorker.js`, which runs FastCDC chunking + BLAKE3
* (the same WASM crate/params as the server) off the UI thread, negotiates
* which chunks the server already has, uploads only the missing ones, and
* commits. Any failure resolves `null` so the caller falls back to a plain
* byte upload — delta is an optimization, never a gate.
*/
import { getCsrfToken } from '$lib/api/csrf';
/** Files smaller than this skip delta: the round-trips cost more than the bytes. */
export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
const DELTA_WORKER_URL = '/workers/deltaWorker.js';
const DELTA_TIMEOUT_BASE_MS = 120_000;
const DELTA_TIMEOUT_PER_GB_MS = 90_000;
export interface DeltaUploadAnswer {
ok: boolean;
data?: unknown;
errorMsg?: string;
isQuotaError?: boolean;
/** Bytes NOT transferred thanks to dedup. */
savedBytes?: number;
}
/** `false` once the environment proved unable to run the worker/WASM. */
let usable: boolean | null = null;
interface ProgressMsg {
type: 'progress';
reusedBytes: number;
uploadedBytes: number;
totalBytes: number;
}
interface FallbackMsg {
type: 'fallback';
reason?: string;
}
interface DoneMsg {
type: 'done';
status: number;
body?: { message?: string; error?: string; still_missing?: unknown };
}
type WorkerMsg = ProgressMsg | FallbackMsg | DoneMsg;
/**
* Try to upload `file` through the delta protocol. Resolves `null` whenever
* the plain byte upload should proceed (too small, environment unusable, any
* transport/protocol failure). `onProgress` receives 0–99 while transferring.
*/
export function tryDeltaUpload(
file: File,
folderId: string | null | undefined,
onProgress?: (pct: number) => void
): Promise<DeltaUploadAnswer | null> {
if (
!folderId ||
file.size < DELTA_UPLOAD_MIN_SIZE ||
usable === false ||
typeof Worker === 'undefined'
) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
let worker: Worker;
try {
worker = new Worker(DELTA_WORKER_URL, { type: 'module' });
} catch {
usable = false;
resolve(null);
return;
}
const sizeGB = file.size / (1024 * 1024 * 1024);
const timeoutMs = DELTA_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * DELTA_TIMEOUT_PER_GB_MS;
let savedBytes = 0;
const settle = (answer: DeltaUploadAnswer | null) => {
clearTimeout(timer);
worker.terminate();
resolve(answer);
};
const timer = setTimeout(() => settle(null), timeoutMs);
worker.onmessage = (event: MessageEvent<WorkerMsg>) => {
const msg = event.data;
if (msg.type === 'progress') {
savedBytes = msg.reusedBytes;
if (onProgress && msg.totalBytes > 0) {
const pct = Math.min(
99,
Math.round((100 * (msg.reusedBytes + msg.uploadedBytes)) / msg.totalBytes)
);
onProgress(pct);
}
return;
}
if (msg.type === 'fallback') {
settle(null);
return;
}
if (msg.type === 'done') {
if (msg.status === 201 || msg.status === 200) {
settle({ ok: true, data: msg.body, savedBytes });
return;
}
const errorMsg =
msg.body?.message || msg.body?.error || `Delta upload failed (HTTP ${msg.status})`;
if (msg.status === 507) {
settle({ ok: false, isQuotaError: true, errorMsg });
return;
}
if (msg.status === 409 && !msg.body?.still_missing) {
settle({ ok: false, errorMsg });
return;
}
settle(null);
}
};
worker.onerror = () => {
usable = false;
settle(null);
};
worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' });
});
}
+22 -2
View File
@@ -7,12 +7,32 @@ export interface DeviceInfo {
scopes?: string;
}
/** Distinguishable failure modes the verify page renders differently. */
export type DeviceLookupError = 'unauthorized' | 'not-found' | 'failed';
/** Thrown by lookupDeviceCode so the page can show a tailored message. */
export class DeviceLookupFailure extends Error {
constructor(readonly kind: DeviceLookupError) {
super(kind);
this.name = 'DeviceLookupFailure';
}
}
/**
* Look up a device user-code. The backend returns HTTP 200 with `{valid:false}`
* for unknown/expired codes (NOT a non-2xx), so the body must be inspected — a
* 2xx alone does not mean the code is good. A 401 means the caller isn't signed
* in and must authenticate before authorizing a device.
*/
export async function lookupDeviceCode(code: string): Promise<DeviceInfo> {
const res = await apiFetch(`/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`device lookup failed: ${res.status}`);
return (await res.json()) as DeviceInfo;
if (res.status === 401) throw new DeviceLookupFailure('unauthorized');
if (!res.ok) throw new DeviceLookupFailure('failed');
const data = (await res.json()) as DeviceInfo & { valid?: boolean };
if (data.valid === false) throw new DeviceLookupFailure('not-found');
return data;
}
export async function decideDevice(userCode: string, action: 'approve' | 'deny'): Promise<void> {
+100
View File
@@ -1,6 +1,7 @@
/** Favorites endpoints — ported from favoritesModel.js + features/library. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { t } from '$lib/i18n/index.svelte';
import {
fetchResourcePage,
type ResourceBody,
@@ -9,12 +10,111 @@ import {
} from './resources';
import type { ItemType } from '$lib/api/types';
/**
* Coarse "how long ago" bucket for date group-bys (favorited/accessed/modified)
* — ported from `normalizeDateBucket` in static/js/core/formatters.js.
*/
export function dateBucket(value: number | string | null | undefined): string | null {
if (value === null || value === undefined) return null;
let date: Date;
if (typeof value === 'number') date = new Date(value < 1e12 ? value * 1000 : value);
else date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
if (diffDays <= 0) return t('dateBucket.today', 'Today');
if (diffDays <= 7) return t('dateBucket.last7days', 'Last 7 days');
if (diffDays <= 30) return t('dateBucket.last30days', 'Last 30 days');
return String(date.getFullYear());
}
/**
* Coarse size bucket label — ported from `sizeBucket`. Pass `null` for folders
* (they receive the "Folders" label).
*/
export function sizeBucket(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return t('sizeBucket.folders', 'Folders');
if (bytes === 0) return t('sizeBucket.empty', 'Empty (0 B)');
if (bytes < 1_048_576) return t('sizeBucket.tiny', '< 1 MB');
if (bytes < 104_857_600) return t('sizeBucket.small', '1 – 100 MB');
if (bytes < 1_073_741_824) return t('sizeBucket.medium', '100 MB – 1 GB');
if (bytes < 5 * 1_073_741_824) return t('sizeBucket.large', '1 – 5 GB');
return t('sizeBucket.huge', '> 5 GB');
}
/** Human label for a resource `category` / type group-by bucket. */
export function typeLabel(category: string): string {
const labels: Record<string, string> = {
Folder: t('groupby.type.folders', 'Folders'),
Image: t('category.images', 'Images'),
Video: t('category.videos', 'Videos'),
Audio: t('category.audio', 'Audio'),
PDF: 'PDF',
Document: t('category.documents', 'Documents'),
Spreadsheet: t('category.spreadsheets', 'Spreadsheets'),
Presentation: t('category.presentations', 'Presentations'),
Archive: t('category.archives', 'Archives'),
Code: t('category.code', 'Code'),
Markdown: t('category.markdown', 'Markdown'),
Text: t('category.text', 'Text'),
Installer: t('category.installers', 'Installers')
};
return labels[category] ?? category;
}
export interface FavoritesResourceItem {
resource_type: ItemType;
favorited_at: string;
resource: ResourceBody;
}
/** userId → resolved display name (best-effort, cached across the session). */
const ownerNameCache = new Map<string, string>();
const ownerInflight = new Map<string, Promise<string>>();
function shortId(id: string): string {
return id.length > 8 ? `${id.slice(0, 8)}…` : id;
}
/**
* Best-effort owner display-name lookup via `/api/users/{id}`, de-duplicated
* and cached. Falls back to a shortened UUID on any failure. Ported from the
* `systemUsers` resolver in the legacy frontend.
*/
export async function resolveOwnerName(ownerId: string): Promise<string> {
if (!ownerId) return '';
const cached = ownerNameCache.get(ownerId);
if (cached) return cached;
const pending = ownerInflight.get(ownerId);
if (pending) return pending;
const promise = (async () => {
let name = shortId(ownerId);
try {
const res = await apiFetch(`/api/users/${encodeURIComponent(ownerId)}`, {
credentials: 'same-origin'
});
if (res.ok) {
const u = (await res.json()) as {
username?: string;
given_name?: string;
family_name?: string;
email?: string;
};
const full = [u.given_name, u.family_name].filter(Boolean).join(' ').trim();
name = u.username || full || u.email || name;
}
} catch {
// keep the UUID fallback
} finally {
ownerInflight.delete(ownerId);
}
ownerNameCache.set(ownerId, name);
return name;
})();
ownerInflight.set(ownerId, promise);
return promise;
}
export function fetchFavoritesPage(
opts?: ResourcePageOpts
): Promise<ResourcePage<FavoritesResourceItem>> {
+30
View File
@@ -18,6 +18,36 @@ export async function uploadFile(folderId: string | null, file: File): Promise<v
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
}
/**
* Upload with progress reporting. `fetch` can't surface upload progress, so this
* uses XHR; CSRF headers are attached the same way as {@link uploadFile}.
* `onProgress` receives a fraction in [0, 1] (or NaN when length is unknown).
*/
export function uploadFileWithProgress(
folderId: string | null,
file: File,
onProgress: (fraction: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const form = new FormData();
if (folderId) form.append('folder_id', folderId);
form.append('file', file);
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files/upload');
xhr.withCredentials = true;
for (const [k, v] of Object.entries(getCsrfHeaders())) xhr.setRequestHeader(k, v);
xhr.upload.onprogress = (e) => {
onProgress(e.lengthComputable ? e.loaded / e.total : NaN);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`upload failed: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('upload failed: network error'));
xhr.send(form);
});
}
export async function renameFile(fileId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/rename`, {
method: 'PUT',
+148 -3
View File
@@ -1,8 +1,140 @@
/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */
import { apiFetch } from '$lib/api/client';
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { ItemType } from '$lib/api/types';
import type { ResourceBody, ResourcePage } from './resources';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export type SubjectType = 'user' | 'group' | 'email' | 'token';
export type ShareRole = 'viewer' | 'editor' | 'admin';
export interface GrantSubject {
type: SubjectType;
id: string;
}
/**
* Subject shape accepted by `POST /api/grants`. The `email` variant feeds the
* invite-by-email flow — the server resolves it to (or provisions) an external
* user. Mirrors the backend `SubjectInputDto`.
*/
export type GrantSubjectInput =
| { type: 'user'; id: string }
| { type: 'group'; id: string }
| { type: 'token'; id: string }
| { type: 'email'; email: string };
/** One grant carries a single permission; a subject's role is derived from all of theirs. */
export interface Grant {
id: string;
granted_at?: string;
granted_by?: string;
subject: GrantSubject;
permission: string;
resource: { type: ItemType; id: string };
expires_at?: string | null;
}
// ── Notification outcomes (PR N1/N2) ─────────────────────────────────────────
export interface NotifyOutcome {
kind: 'sent' | 'coalesced' | 'rate_limited' | 'not_applicable';
detail?: string;
last_sent_at?: string;
retry_after_secs?: number;
reason?: string;
}
export interface NotifyOutcomeSet {
total_recipients: number;
outcomes: NotifyOutcome[];
}
export interface CreateGrantResponse {
grants: Grant[];
notification: NotifyOutcomeSet;
}
export function roleFromPermissions(perms: Iterable<string>): ShareRole {
const set = new Set(perms);
if (set.has('delete') || set.has('share')) return 'admin';
if (set.has('create') || set.has('update')) return 'editor';
return 'viewer';
}
/** Convert a YYYY-MM-DD date (or null) to an ISO-8601 datetime at midnight UTC. */
export function expiryToIso(date: string | null | undefined): string | null {
return date ? new Date(`${date}T00:00:00Z`).toISOString() : null;
}
export function fetchGrantsForResource(type: ItemType, id: string): Promise<Grant[]> {
const params = new URLSearchParams({ resource_type: type, resource_id: id });
return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' });
}
export async function createGrant(
subject: GrantSubjectInput,
resource: { type: ItemType; id: string },
role: ShareRole,
expiresAt?: string | null
): Promise<CreateGrantResponse> {
const res = await apiFetch('/api/grants', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(e.error || `create grant failed: ${res.status}`);
}
return (await res.json()) as CreateGrantResponse;
}
export async function updateGrantRole(
subject: GrantSubject,
resource: { type: ItemType; id: string },
role: ShareRole,
expiresAt?: string | null
): Promise<void> {
const res = await apiFetch('/api/grants/role', {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ subject, resource, role, expires_at: expiresAt ?? null })
});
if (!res.ok) throw new Error(`update role failed: ${res.status}`);
}
export async function revokeGrant(grantId: string): Promise<void> {
const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`revoke grant failed: ${res.status}`);
}
/**
* Resend / send a share notification for a single grant.
* `POST /api/grants/{id}/notify`. Returns the aggregated outcome set, or a
* `rate_limited` summary when the whole call was rate-limited (HTTP 429).
*/
export async function notifyGrantRecipient(grantId: string): Promise<NotifyOutcomeSet> {
const res = await apiFetch(`/api/grants/${encodeURIComponent(grantId)}/notify`, {
method: 'POST',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (res.status === 204) return { total_recipients: 0, outcomes: [] };
if (res.status === 429) {
return { total_recipients: 1, outcomes: [{ kind: 'rate_limited' }] };
}
if (res.ok) return (await res.json()) as NotifyOutcomeSet;
throw new Error(`notify failed: ${res.status}`);
}
export interface IncomingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
@@ -11,12 +143,25 @@ export interface IncomingGrantItem {
role?: string;
}
/** One (subject, permissions) entry within an outgoing resource item. */
export interface OutgoingResourceGrant {
grant_id: string;
subject_type: 'user' | 'group' | 'token';
subject_id: string;
subject_display: string;
role: ShareRole;
granted_at: string;
expires_at?: string | null;
has_password: boolean;
is_external: boolean;
}
export interface OutgoingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
subject?: string;
first_shared_at?: string;
role?: string;
/** One entry per (subject, permissions) pair. */
grants: OutgoingResourceGrant[];
}
interface GrantsPageOpts {
+72 -12
View File
@@ -1,22 +1,40 @@
/** Group (ReBAC) endpoints — ported from model/groups.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { t } from '$lib/i18n/index.svelte';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const enc = encodeURIComponent;
/**
* Well-known UUID of the predefined "Internal" virtual group (matches the
* Rust constant `INTERNAL_GROUP_ID` in `src/domain/entities/subject_group.rs`
* and the legacy `model/groups.js`).
*/
export const INTERNAL_GROUP_ID = '00000000-0000-0000-0000-000000000001';
/**
* Map of well-known virtual-group UUIDs → i18n key for the human-readable
* display name. Anything not in this map falls back to `group.name`. Ported
* from `components/groupDisplay.js`.
*/
const VIRTUAL_NAME_KEYS: Record<string, string> = {
[INTERNAL_GROUP_ID]: 'groups.virtual_internal_name'
};
export interface GroupItem {
id: string;
name: string;
description?: string | null;
member_count?: number;
is_virtual?: boolean;
can_manage?: boolean;
}
/** The members endpoint returns a tagged union: `{ kind: 'user' | 'group', id }`. */
export interface GroupMember {
user_id?: string;
group_id?: string;
email?: string;
name?: string;
kind: 'user' | 'group';
id: string;
}
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
@@ -29,16 +47,53 @@ async function mutate(url: string, method: string, body?: unknown): Promise<void
if (!res.ok) throw new Error(`${method} ${url} failed: ${res.status}`);
}
/** The list endpoint may return an array or `{ groups | items, total }`. */
export async function listGroups(limit = 50, offset = 0, q?: string): Promise<GroupItem[]> {
/** A single page of groups plus the server-reported total (for "Load more"). */
export interface GroupPage {
items: GroupItem[];
total: number;
}
/**
* Fetch one page of groups. The list endpoint may return an array or
* `{ groups | items, total }`. When no total is provided we fall back to the
* page length so pagination collapses gracefully to a single page.
*/
export async function listGroupsPage(limit = 50, offset = 0, q?: string): Promise<GroupPage> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
const data = await apiJson<GroupItem[] | { groups?: GroupItem[]; items?: GroupItem[] }>(
`/api/groups?${params}`,
{ credentials: 'same-origin' }
);
if (Array.isArray(data)) return data;
return data.groups ?? data.items ?? [];
const data = await apiJson<
GroupItem[] | { groups?: GroupItem[]; items?: GroupItem[]; total?: number }
>(`/api/groups?${params}`, { credentials: 'same-origin' });
if (Array.isArray(data)) return { items: data, total: offset + data.length };
const items = data.groups ?? data.items ?? [];
return { items, total: data.total ?? offset + items.length };
}
/** Convenience wrapper returning just the items of the first page. */
export async function listGroups(limit = 50, offset = 0, q?: string): Promise<GroupItem[]> {
return (await listGroupsPage(limit, offset, q)).items;
}
/**
* Human-readable display name for a group. Virtual groups get a translated
* label via the well-known UUID mapping; user-defined groups display their
* raw name. Ported from `components/groupDisplay.js`.
*/
export function groupDisplayName(group: GroupItem): string {
if (group.is_virtual) {
const key = VIRTUAL_NAME_KEYS[group.id];
if (key) return t(key, group.name);
}
return group.name;
}
/**
* Pick the icon registry name for a group avatar. Virtual (system-wide)
* groups use `people-roof`; user-defined groups use `user-group`. Ported from
* `components/groupDisplay.js`.
*/
export function groupIconName(group: Pick<GroupItem, 'is_virtual'>): string {
return group.is_virtual ? 'people-roof' : 'user-group';
}
export function createGroup(name: string, description?: string | null): Promise<void> {
@@ -61,6 +116,11 @@ export function addUserMember(groupId: string, userId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members`, 'POST', { user_id: userId });
}
/** Add another group as a nested member. Backend enforces cycle + depth limits. */
export function addGroupMember(groupId: string, memberGroupId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members`, 'POST', { group_id: memberGroupId });
}
export function removeUserMember(groupId: string, userId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members/user/${enc(userId)}`, 'DELETE');
}
+68 -3
View File
@@ -32,6 +32,20 @@ export interface PlaylistItem {
duration_secs: number | null;
}
/** A user a playlist is shared with (`/api/playlists/{id}/shares`). */
export interface MusicShare {
user_id: string;
can_write: boolean | null;
}
/** Fields that can be patched on a playlist via PUT. */
export interface PlaylistUpdate {
name?: string;
description?: string | null;
is_public?: boolean;
cover_file_id?: string | null;
}
export function listPlaylists(): Promise<Playlist[]> {
return apiJson<Playlist[]>('/api/playlists', { credentials: 'same-origin' });
}
@@ -53,14 +67,19 @@ export async function createPlaylist(name: string): Promise<Playlist> {
return (await res.json()) as Playlist;
}
export async function renamePlaylist(playlistId: string, name: string): Promise<void> {
/** Patch one or more playlist fields (name, description, public flag, cover). */
export async function updatePlaylist(playlistId: string, patch: PlaylistUpdate): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
body: JSON.stringify(patch)
});
if (!res.ok) throw new Error(`rename playlist failed: ${res.status}`);
if (!res.ok) throw new Error(`update playlist failed: ${res.status}`);
}
export function renamePlaylist(playlistId: string, name: string): Promise<void> {
return updatePlaylist(playlistId, { name });
}
export async function deletePlaylist(playlistId: string): Promise<void> {
@@ -101,3 +120,49 @@ export async function reorderTracks(playlistId: string, itemIds: string[]): Prom
});
if (!res.ok) throw new Error(`reorder failed: ${res.status}`);
}
export function listShares(playlistId: string): Promise<MusicShare[]> {
return apiJson<MusicShare[]>(`/api/playlists/${playlistId}/shares`, {
credentials: 'same-origin'
});
}
export async function sharePlaylist(
playlistId: string,
userId: string,
canWrite = false
): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/share`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ user_id: userId, can_write: canWrite })
});
if (!res.ok) throw new Error(`share playlist failed: ${res.status}`);
}
export async function removeShare(playlistId: string, userId: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/share/${encodeURIComponent(userId)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`remove share failed: ${res.status}`);
}
/** Upload an image and return its new file id (used to set a playlist cover). */
export async function uploadCoverImage(file: File, folderId = ''): Promise<string> {
const form = new FormData();
form.append('file', file);
form.append('folder_id', folderId);
const res = await apiFetch('/api/files/upload', {
method: 'POST',
credentials: 'same-origin',
headers: getCsrfHeaders(),
body: form
});
if (!res.ok) throw new Error(`cover upload failed: ${res.status}`);
const uploaded = (await res.json()) as { id?: string };
if (!uploaded.id) throw new Error('cover upload returned no file id');
return uploaded.id;
}
+76
View File
@@ -1,5 +1,6 @@
/** Photos timeline endpoint — ported from features/library/photos.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem } from '$lib/api/types';
export interface PhotoPage {
@@ -7,6 +8,28 @@ export interface PhotoPage {
nextCursor: string | null;
}
/** EXIF metadata returned by `/api/files/{id}/metadata` (subset used by the lightbox). */
export interface FileMetadata {
file_id: string;
captured_at?: number;
latitude?: number | null;
longitude?: number | null;
camera_make?: string | null;
camera_model?: string | null;
orientation?: number | null;
width?: number | null;
height?: number | null;
}
/** Result of a batch trash request (200 = all, 206 = partial success). */
export interface BatchTrashResult {
successful: string[];
failed: string[];
}
/** Backend `MAX_BATCH_SIZE` — chunk larger selections into separate requests. */
const BATCH_CHUNK_SIZE = 1000;
/**
* Fetch one page of the photo timeline. The next-page cursor is returned in the
* `X-Next-Cursor` response header; the page is the last one when fewer than
@@ -24,3 +47,56 @@ export async function fetchPhotos(limit = 60, before?: string | null): Promise<P
nextCursor: cursor && items && items.length >= limit ? cursor : null
};
}
/** Fetch EXIF metadata for a file. Returns `null` on any error (non-critical). */
export async function fetchFileMetadata(fileId: string): Promise<FileMetadata | null> {
try {
const res = await apiFetch(`/api/files/${fileId}/metadata`, { credentials: 'same-origin' });
if (!res.ok) return null;
return (await res.json()) as FileMetadata;
} catch {
return null;
}
}
/**
* Move files to trash in batches via `POST /api/batch/trash`. One request per
* chunk (up to {@link BATCH_CHUNK_SIZE} ids); 200 = all trashed, 206 = partial.
* Returns the set of ids that were actually trashed across all chunks.
*/
export async function batchTrash(fileIds: string[]): Promise<Set<string>> {
const trashed = new Set<string>();
for (let i = 0; i < fileIds.length; i += BATCH_CHUNK_SIZE) {
const chunk = fileIds.slice(i, i + BATCH_CHUNK_SIZE);
const res = await apiFetch('/api/batch/trash', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ file_ids: chunk, folder_ids: [] })
});
// 200 = all trashed, 206 = partial; both carry `successful`.
if (!res.ok && res.status !== 206) continue;
const data = (await res.json().catch(() => ({}))) as Partial<BatchTrashResult>;
const ok = Array.isArray(data?.successful) ? data.successful : chunk;
for (const id of ok) trashed.add(id);
}
return trashed;
}
/**
* Upload a generated thumbnail blob for a file at a given size. Used by the
* photos grid to persist client-generated video frames server-side.
*/
export async function uploadThumbnail(
fileId: string,
size: 'icon' | 'preview' | 'large',
blob: Blob,
contentType = 'image/jpeg'
): Promise<void> {
await apiFetch(`/api/files/${fileId}/thumbnail/${size}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...getCsrfHeaders(), 'Content-Type': contentType },
body: blob
});
}
+80 -2
View File
@@ -2,6 +2,7 @@
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { User } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
@@ -20,7 +21,32 @@ export async function updateProfile(patch: ProfilePatch): Promise<User> {
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(patch)
});
if (!res.ok) throw new Error(`profile update failed: ${res.status}`);
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
// 409 covers two distinct conflicts that share the same status. The
// server's audit log carries the structured `reason`; the JSON body
// only exposes a human-readable message, so we branch on that.
if (res.status === 409) {
const msg = (err.message || err.error || '').toLowerCase();
const key = msg.includes('already claimed')
? 'profile.username_immutable_error'
: 'profile.username_taken_error';
const fallback = msg.includes('already claimed')
? "Your username has already been set and can't be changed."
: 'That username is already taken.';
throw new Error(t(key, fallback));
}
// 403 here means the field is governed by the identity provider.
if (res.status === 403) {
throw new Error(
t(
'profile.edit_oidc_managed',
'Your profile is managed by your identity provider. Update it there; changes appear on your next sign-in.'
)
);
}
throw new Error(err.message || err.error || `profile update failed: ${res.status}`);
}
return (await res.json()) as User;
}
@@ -34,7 +60,7 @@ export async function changePassword(currentPw: string, newPw: string): Promise<
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
}
export async function updateAvatar(image: string): Promise<void> {
export async function updateAvatar(image: string | null): Promise<void> {
const res = await apiFetch('/api/auth/me/image', {
method: 'PUT',
credentials: 'same-origin',
@@ -43,3 +69,55 @@ export async function updateAvatar(image: string): Promise<void> {
});
if (!res.ok) throw new Error(`avatar update failed: ${res.status}`);
}
export interface AppPassword {
id: string;
label: string;
active?: boolean;
created_at: string;
last_used_at?: string;
}
/**
* Labels the server uses for sessions auto-generated when a Nextcloud-style
* client authenticates (vs. user-created app passwords). Ported from
* `views/profile/profile.js`'s `AUTO_LABELS`.
*/
const AUTO_LABELS = ['Nextcloud', 'Nextcloud (OIDC)'];
/** True when an app password was auto-generated by a client session login. */
export function isAutoAppPassword(pw: Pick<AppPassword, 'label'>): boolean {
return AUTO_LABELS.includes(pw.label);
}
export async function listAppPasswords(): Promise<AppPassword[]> {
const res = await apiFetch('/api/auth/app-passwords', { credentials: 'same-origin' });
if (!res.ok) return [];
const data = (await res.json()) as AppPassword[] | { app_passwords?: AppPassword[] };
return Array.isArray(data) ? data : (data.app_passwords ?? []);
}
/** Returns the one-time generated password (shown once). */
export async function createAppPassword(label: string): Promise<string> {
const res = await apiFetch('/api/auth/app-passwords', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ label })
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `create app password failed: ${res.status}`);
}
const data = (await res.json()) as { password: string };
return data.password;
}
export async function revokeAppPassword(id: string): Promise<void> {
const res = await apiFetch(`/api/auth/app-passwords/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`revoke app password failed: ${res.status}`);
}
@@ -0,0 +1,166 @@
/**
* Recipient search for the share People tab — system users (via the system
* address book) + groups (via /api/groups/search) + a synthesized "invite by
* email" suggestion when the query parses as an email. Ported from the original
* shareModal recipient autocomplete (addressBook.searchContacts + _searchGroups
* + _looksLikeEmail).
*/
import { apiFetch } from '$lib/api/client';
import { session } from '$lib/stores/session.svelte';
import type { SubjectType } from './grants';
export interface Recipient {
type: Extract<SubjectType, 'user' | 'group' | 'email'>;
/** For email recipients this is the normalised email; for users/groups, the UUID. */
id: string;
label: string;
sublabel?: string;
}
interface Contact {
id: string;
first_name?: string;
last_name?: string;
full_name?: string;
email?: Array<{ email: string; is_primary?: boolean }>;
}
interface GroupResult {
id: string;
name: string;
}
/**
* Permissive client-side email check — matches a non-whitespace local part, an
* `@`, and a domain with a dot. The server's `normalize_email` is authoritative;
* this just decides whether to surface the synthetic invite-by-email row.
*/
function looksLikeEmail(q: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q);
}
// The system book lists all users; we filter client-side (matches the original).
let contactCache: Contact[] | null = null;
/** `false` once we confirm the system address book is unavailable. */
let directoryAvailable: boolean | null = null;
async function systemContacts(): Promise<Contact[]> {
if (contactCache) return contactCache;
try {
const res = await apiFetch('/api/address-books/system/contacts', {
credentials: 'same-origin'
});
if (!res.ok) {
directoryAvailable = false;
contactCache = [];
return contactCache;
}
directoryAvailable = true;
contactCache = (await res.json()) as Contact[];
} catch {
directoryAvailable = false;
contactCache = [];
}
return contactCache;
}
/**
* Whether the system user directory is reachable. Returns `true` until proven
* otherwise so callers degrade gracefully; call `ensureResolvers()` first to
* get an accurate answer.
*/
export function isDirectoryAvailable(): boolean {
return directoryAvailable !== false;
}
function contactLabel(c: Contact): { label: string; email: string } {
const name = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || '';
const email = c.email?.find((e) => e.is_primary)?.email ?? c.email?.[0]?.email ?? '';
return { label: name || email || c.id, email };
}
async function searchGroups(q: string): Promise<Recipient[]> {
try {
const res = await apiFetch(`/api/groups/search?q=${encodeURIComponent(q)}&limit=8`, {
credentials: 'same-origin'
});
if (!res.ok) return [];
const groups = (await res.json()) as GroupResult[];
return groups.map((g) => ({ type: 'group' as const, id: g.id, label: g.name }));
} catch {
return [];
}
}
// ── Label resolution for existing grants (subject id → display name) ────────
let groupCache: Map<string, string> | null = null;
async function loadGroups(): Promise<Map<string, string>> {
if (groupCache) return groupCache;
groupCache = new Map();
try {
const res = await apiFetch('/api/groups/search?q=&limit=200', { credentials: 'same-origin' });
if (res.ok) {
for (const g of (await res.json()) as GroupResult[]) groupCache.set(g.id, g.name);
}
} catch {
/* leave empty */
}
return groupCache;
}
/** Preload the user + group caches so grant rows can show names. */
export async function ensureResolvers(): Promise<void> {
await Promise.all([systemContacts(), loadGroups()]);
}
/** Resolve a subject id to a display label using the preloaded caches. */
export function resolveLabel(type: 'user' | 'group', id: string): string {
if (type === 'group') return groupCache?.get(id) ?? id;
const c = contactCache?.find((x) => x.id === id);
return c ? contactLabel(c).label : id;
}
/** Resolve a subject id to a label + sublabel (email) for member vignettes. */
export function resolveRecipient(type: 'user' | 'group', id: string): Recipient {
if (type === 'group') {
return { type: 'group', id, label: groupCache?.get(id) ?? id };
}
const c = contactCache?.find((x) => x.id === id);
if (!c) return { type: 'user', id, label: id };
const { label, email } = contactLabel(c);
return { type: 'user', id, label, sublabel: email };
}
/**
* Combined user + group results matching the query (case-insensitive), plus a
* synthetic invite-by-email suggestion when the query is an email that no
* contact already owns. The current logged-in user is excluded — you can't
* share with yourself. Capped at 8 combined (groups, then users, then email).
*/
export async function searchRecipients(query: string): Promise<Recipient[]> {
const q = query.toLowerCase().trim();
if (!q) return [];
const currentUserId = session.user?.id ?? null;
const [contacts, groups] = await Promise.all([systemContacts(), searchGroups(q)]);
const matched = contacts
.filter((c) => c.id !== currentUserId)
.map((c) => ({ c, ...contactLabel(c) }))
.filter(
({ label, email }) => label.toLowerCase().includes(q) || email.toLowerCase().includes(q)
);
const users: Recipient[] = matched.map(({ c, label, email }) => ({
type: 'user' as const,
id: c.id,
label,
sublabel: email
}));
const emailItems: Recipient[] = [];
if (looksLikeEmail(q)) {
const exists = matched.some(({ email }) => email.toLowerCase() === q);
if (!exists) emailItems.push({ type: 'email', id: q, label: q });
}
return [...groups, ...users, ...emailItems].slice(0, 8);
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Shared cursor-pagination helper for the favorites/recent/trash "resources"
* endpoints, which all take the same query params. Ported from the legacy
* endpoints, which all take the same query params. Ported from the original
* favoritesModel/recentModel/trashModel.
*/
import { apiFetch } from '$lib/api/client';
+77
View File
@@ -0,0 +1,77 @@
/** Search endpoint — ported from features/files/search.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import type { SearchResults, SortBy } from '$lib/api/types';
export interface SearchOptions {
folderId?: string;
recursive?: boolean;
fileTypes?: string[];
minSize?: number;
maxSize?: number;
/** Unix-seconds lower bound on created time. */
createdAfter?: number;
/** Unix-seconds upper bound on created time. */
createdBefore?: number;
/** Unix-seconds lower bound on modified time. */
modifiedAfter?: number;
/** Unix-seconds upper bound on modified time. */
modifiedBefore?: number;
limit?: number;
offset?: number;
sortBy?: SortBy;
}
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
const params = new URLSearchParams();
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
for (const ft of opts.fileTypes ?? []) params.append('type', ft);
if (opts.minSize != null) params.append('min_size', String(opts.minSize));
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
params.append('limit', String(opts.limit ?? 100));
params.append('offset', String(opts.offset ?? 0));
params.append('sort_by', opts.sortBy ?? 'relevance');
return apiJson<SearchResults>(`/api/search?${params.toString()}`, { credentials: 'same-origin' });
}
/** A single autocomplete suggestion returned by the lightweight suggest endpoint. */
export interface SearchSuggestions {
suggestions: string[];
query_time_ms: number;
}
export interface SuggestOptions {
folderId?: string;
limit?: number;
}
/**
* Lightweight autocomplete suggestions from the backend `GET /api/search/suggest`
* endpoint — name-only hints without the full search overhead.
*/
export function searchSuggest(
query: string,
opts: SuggestOptions = {}
): Promise<SearchSuggestions> {
const params = new URLSearchParams();
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.limit != null) params.append('limit', String(opts.limit));
return apiJson<SearchSuggestions>(`/api/search/suggest?${params.toString()}`, {
credentials: 'same-origin'
});
}
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
export async function clearSearchCache(): Promise<void> {
const res = await apiFetch('/api/search/cache', {
method: 'DELETE',
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`Failed to clear search cache: ${res.status} ${res.statusText}`);
}
+4 -1
View File
@@ -31,7 +31,8 @@ export interface ShareListing {
export type ShareMetaResult =
| { status: 'ok'; data: ShareMeta }
| { status: 'password' }
| { status: 'expired' };
| { status: 'expired' }
| { status: 'invalid' };
const enc = encodeURIComponent;
@@ -44,6 +45,8 @@ export async function getShareMeta(token: string): Promise<ShareMetaResult> {
throw new Error('Unauthorized');
}
if (res.status === 410) return { status: 'expired' };
// 404 means the token doesn't resolve to any share — a bad/typo'd link.
if (res.status === 404) return { status: 'invalid' };
throw new Error(`HTTP ${res.status}`);
}
+110
View File
@@ -0,0 +1,110 @@
/** Public share-link endpoints (/api/shares) — ported from features/sharing. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { ItemType, ShareItem } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export interface CreateShareInput {
itemId: string;
/** Optional human-readable link name (stored as `item_name`). */
itemName?: string | null;
itemType: ItemType;
password?: string | null;
/** ISO date string or null; converted to epoch seconds for the wire. */
expiresAt?: string | null;
}
export async function createShare(input: CreateShareInput): Promise<ShareItem> {
const body = {
item_id: input.itemId,
item_name: input.itemName ?? null,
item_type: input.itemType,
password: input.password || null,
expires_at: input.expiresAt ? Math.floor(new Date(input.expiresAt).getTime() / 1000) : null
};
const res = await apiFetch('/api/shares', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(e.error || `create share failed: ${res.status}`);
}
return (await res.json()) as ShareItem;
}
export async function listSharesForItem(itemId: string, itemType: ItemType): Promise<ShareItem[]> {
const params = new URLSearchParams({ item_id: itemId, item_type: itemType });
const res = await apiFetch(`/api/shares?${params}`, { credentials: 'same-origin' });
if (!res.ok) return [];
const data = (await res.json()) as ShareItem[] | { items?: ShareItem[] };
return Array.isArray(data) ? data : (data.items ?? []);
}
/** Fetch a single share by its UUID (used to resolve a token's URL on demand). */
export async function getShareById(shareId: string): Promise<ShareItem> {
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`get share failed: ${res.status}`);
return (await res.json()) as ShareItem;
}
export interface UpdateShareInput {
/** `null` clears the password; omit to leave it unchanged. */
password?: string | null;
/** ISO date string clears/sets; converted to epoch seconds. `null` clears. */
expiresAt?: string | null;
}
/**
* Edit an existing public link's password and/or expiry.
* `PUT /api/shares/{id}` with `{ password, expires_at }`.
*/
export async function updateShare(shareId: string, input: UpdateShareInput): Promise<ShareItem> {
const body: { password?: string | null; expires_at?: number | null } = {};
if (input.password !== undefined) body.password = input.password;
if (input.expiresAt !== undefined) {
body.expires_at = input.expiresAt
? Math.floor(new Date(input.expiresAt).getTime() / 1000)
: null;
}
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(e.error || `update share failed: ${res.status}`);
}
return (await res.json()) as ShareItem;
}
export async function deleteShare(shareId: string): Promise<void> {
const res = await apiFetch(`/api/shares/${shareId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok && res.status !== 204) throw new Error(`delete share failed: ${res.status}`);
}
/**
* Copy a share URL to the clipboard, resolving it against the current origin.
* Shared by the dialog and My Shares so copy-link logic lives in one place.
* Returns `true` on success.
*/
export async function copyShareLink(url: string): Promise<boolean> {
try {
const absolute = typeof location !== 'undefined' ? new URL(url, location.origin).href : url;
await navigator.clipboard.writeText(absolute);
return true;
} catch {
return false;
}
}
+76
View File
@@ -1,6 +1,7 @@
/** Trash endpoints — ported from trashModel.js + views/trash. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import { t } from '$lib/i18n/index.svelte';
import { fetchResourcePage, type ResourcePage, type ResourcePageOpts } from './resources';
import type { TrashResourceItem } from '$lib/api/types';
@@ -8,6 +9,81 @@ export function fetchTrashPage(opts?: ResourcePageOpts): Promise<ResourcePage<Tr
return fetchResourcePage<TrashResourceItem>('/api/trash/resources', 'deletion_date', opts);
}
/** Days from now until `value` (negative when already past). */
function daysUntil(value: number | string | Date | null | undefined): number | null {
if (value === null || value === undefined) return null;
let date: Date;
if (value instanceof Date) date = value;
else if (typeof value === 'number') date = new Date(value < 1e12 ? value * 1000 : value);
else date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.floor((date.getTime() - Date.now()) / 86_400_000);
}
export type ExpiryTier = 'never' | 'normal' | 'caution' | 'soon' | 'urgent' | 'expired';
export interface ExpiryChip {
tier: ExpiryTier;
icon: string;
label: string;
}
/**
* Tiered "remaining lifetime" chip for a trash deletion date — ported from
* `formatExpiryChip` in static/js/core/formatters.js. `null` means "Never".
*/
export function expiryChip(value: number | string | null | undefined): ExpiryChip {
if (value === null || value === undefined) {
return { tier: 'never', icon: 'infinity', label: t('expiryChip.never', 'Never expires') };
}
const days = daysUntil(value);
if (days === null) {
return { tier: 'normal', icon: 'calendar', label: String(value) };
}
if (days < 0)
return {
tier: 'expired',
icon: 'exclamation-triangle',
label: t('expiryChip.expired', 'Expired')
};
if (days === 0)
return { tier: 'urgent', icon: 'clock', label: t('expiryChip.today', 'Expires today') };
if (days === 1)
return { tier: 'urgent', icon: 'clock', label: t('expiryChip.tomorrow', 'Expires tomorrow') };
if (days <= 7)
return {
tier: 'soon',
icon: 'calendar',
label: t('expiryChip.inDays', { count: days }, 'Expires in {{count}} days')
};
if (days <= 30)
return {
tier: 'caution',
icon: 'calendar',
label: t('expiryChip.inDays', { count: days }, 'Expires in {{count}} days')
};
return {
tier: 'normal',
icon: 'calendar',
label: t('expiryChip.onDate', { count: days }, 'Expires in {{count}} days')
};
}
/**
* Coarse "remaining days" bucket label for the trash group-by swimlanes —
* ported from `normalizeExpiryBucket`.
*/
export function remainingDaysBucket(value: number | string | null | undefined): string {
const days = daysUntil(value);
if (days === null) return t('expiryBucket.noExpiry', 'No expiration');
if (days < 0) return t('expiryBucket.expired', 'Expired');
if (days === 0) return t('expiryBucket.today', 'Today');
if (days === 1) return t('expiryBucket.tomorrow', 'Tomorrow');
if (days <= 7) return t('expiryBucket.week', 'In less than 7 days');
if (days <= 30) return t('expiryBucket.month', 'In less than 30 days');
return t('expiryBucket.later', 'Later');
}
export async function restoreTrashItem(trashId: string): Promise<void> {
const res = await apiFetch(`/api/trash/${trashId}/restore`, {
method: 'POST',
+85
View File
@@ -0,0 +1,85 @@
/**
* WOPI (Collabora / OnlyOffice) integration — ported from features/files/wopiEditor.js.
* `getEditorUrl` returns the iframe action URL + access token; the office editor
* is launched by POST-ing the token to that URL (see WopiEditor.svelte).
*/
import { apiFetch } from '$lib/api/client';
export interface WopiEditorData {
editor_url: string;
access_token: string;
access_token_ttl: string | number;
}
const FALLBACK_EXTS = [
'docx',
'doc',
'odt',
'rtf',
'txt',
'xlsx',
'xls',
'ods',
'csv',
'pptx',
'ppt',
'odp'
];
let cachedExts: string[] | null = null;
export async function getSupportedExtensions(): Promise<string[]> {
if (cachedExts) return cachedExts;
try {
const res = await fetch('/wopi/supported-extensions');
if (res.ok) {
const exts = (await res.json()) as string[];
if (Array.isArray(exts) && exts.length > 0) {
cachedExts = exts;
return exts;
}
}
} catch {
/* fall through to the hardcoded list */
}
cachedExts = FALLBACK_EXTS;
return cachedExts;
}
export async function canEditWithWopi(filename: string): Promise<boolean> {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
return (await getSupportedExtensions()).includes(ext);
}
export async function getEditorUrl(
fileId: string,
action: 'edit' | 'view' = 'edit'
): Promise<WopiEditorData> {
const res = await apiFetch(
`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`,
{ credentials: 'same-origin' }
);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Editor URL request failed: ${res.status} ${text}`);
}
return (await res.json()) as WopiEditorData;
}
/** PDFs are view-only in WOPI: an edit request returns 422 → retry as view. */
export async function getEditorUrlWithFallback(
fileId: string,
filename: string,
action: 'edit' | 'view' = 'edit'
): Promise<WopiEditorData> {
try {
return await getEditorUrl(fileId, action);
} catch (e) {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const msg = e instanceof Error ? e.message : '';
if (action === 'edit' && ext === 'pdf' && msg.includes('422')) {
return getEditorUrl(fileId, 'view');
}
throw e;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout } from '$lib/api/endpoints/auth';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme } from '$lib/stores/theme.svelte';
interface Command {
id: string;
label: string;
icon: string;
hint?: string;
run: () => void;
}
let open = $state(false);
// Drives the enter animation: flipped on after mount so the overlay/panel
// transition from their initial (faded/offset) state.
let entered = $state(false);
let query = $state('');
let index = $state(0);
let input = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
let fileMatches = $state<Command[]>([]);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Element focused before the palette opened, restored on close.
let prevFocus: HTMLElement | null = null;
function close() {
open = false;
entered = false;
query = '';
fileMatches = [];
index = 0;
prevFocus?.focus?.();
prevFocus = null;
}
function nav(path: string): Command['run'] {
return () => {
close();
void goto(path);
};
}
/**
* Trigger the file picker in the files view. The input lives in the files
* route, so we navigate there first and broadcast an event the page listens
* for. (Follow-up: wire `oxicloud:upload-files` in the files route page.)
*/
function uploadFiles() {
close();
void goto('/files').then(() => {
window.dispatchEvent(new CustomEvent('oxicloud:upload-files'));
});
}
async function showAbout() {
close();
await confirmDialog({
title: t('user_menu.about', 'About OxiCloud'),
message: t(
'about.description',
'OxiCloud — a fast, self-hosted file storage and sync server.'
),
confirmText: t('common.ok', 'OK'),
cancelText: t('common.close', 'Close')
});
}
const baseCommands = $derived.by<Command[]>(() => {
const cmds: Command[] = [
{ id: 'files', label: t('nav.files', 'Files'), icon: 'folder', run: nav('/files') },
{ id: 'shared', label: t('nav.shared', 'Shared'), icon: 'oxiexport', run: nav('/shared') },
{
id: 'swm',
label: t('nav.shared_with_me', 'Shared with me'),
icon: 'oxiimport',
run: nav('/shared-with-me')
},
{ id: 'recent', label: t('nav.recent', 'Recent'), icon: 'clock', run: nav('/recent') },
{ id: 'fav', label: t('nav.favorites', 'Favorites'), icon: 'star', run: nav('/favorites') },
{ id: 'photos', label: t('nav.photos', 'Photos'), icon: 'images', run: nav('/photos') },
{ id: 'music', label: t('nav.music', 'Music'), icon: 'music', run: nav('/music') },
{ id: 'groups', label: t('nav.groups', 'Groups'), icon: 'users', run: nav('/groups') },
{ id: 'trash', label: t('nav.trash', 'Trash'), icon: 'trash', run: nav('/trash') },
{
id: 'upload',
label: t('actions.upload_files', 'Upload files'),
icon: 'cloud-upload-alt',
run: uploadFiles
},
{
id: 'profile',
label: t('user_menu.profile', 'Profile'),
icon: 'user',
run: nav('/profile')
}
];
if (session.user?.role === 'admin') {
cmds.push({
id: 'admin',
label: t('user_menu.admin_panel', 'Admin'),
icon: 'shield-alt',
run: nav('/admin')
});
}
cmds.push(
{
id: 'theme',
label: t('cmdk.toggle_theme', 'Toggle theme'),
icon: 'moon',
run: () => {
theme.set(theme.current === 'dark' ? 'light' : 'dark');
close();
}
},
{
id: 'about',
label: t('user_menu.about', 'About'),
icon: 'info-circle',
run: showAbout
},
{
id: 'logout',
label: t('actions.logout', 'Log out'),
icon: 'sign-out-alt',
run: async () => {
close();
try {
await logout();
} catch {
/* clear locally regardless */
}
session.reset();
await goto('/login');
}
}
);
return cmds;
});
const filtered = $derived.by<Command[]>(() => {
const q = query.trim().toLowerCase();
const base = q ? baseCommands.filter((c) => c.label.toLowerCase().includes(q)) : baseCommands;
return [...base, ...fileMatches];
});
function runFileSearch() {
if (searchTimer) clearTimeout(searchTimer);
const q = query.trim();
if (q.length < 2) {
fileMatches = [];
return;
}
searchTimer = setTimeout(async () => {
try {
const r = await searchFiles(q, { recursive: true, limit: 5 });
const folders: Command[] = r.folders.slice(0, 3).map((f) => ({
id: `fld-${f.id}`,
label: f.name,
icon: 'folder',
hint: t('files.folder', 'Folder'),
run: nav(`/files/${f.id}`)
}));
const files: Command[] = r.files.slice(0, 5).map((f) => ({
id: `fil-${f.id}`,
label: f.name,
icon: 'file',
hint: t('files.file', 'File'),
run: () => {
close();
window.open(fileInlineUrl(f.id), '_blank', 'noopener');
}
}));
fileMatches = [...folders, ...files];
} catch {
fileMatches = [];
}
}, 250);
}
function scrollActiveIntoView() {
queueMicrotask(() => {
listEl?.querySelector('.cmdk__item.active')?.scrollIntoView({ block: 'nearest' });
});
}
function onGlobalKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
if (open) {
close();
} else {
prevFocus = document.activeElement as HTMLElement | null;
open = true;
requestAnimationFrame(() => (entered = true));
queueMicrotask(() => input?.focus());
}
} else if (open && e.key === 'Escape') {
close();
}
}
function onListKey(e: KeyboardEvent) {
const items = filtered;
if (e.key === 'ArrowDown') {
e.preventDefault();
index = Math.min(index + 1, items.length - 1);
scrollActiveIntoView();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
index = Math.max(index - 1, 0);
scrollActiveIntoView();
} else if (e.key === 'Enter') {
e.preventDefault();
items[index]?.run();
}
}
$effect(() => {
void query;
index = 0;
runFileSearch();
});
</script>
<svelte:window onkeydown={onGlobalKey} />
{#if open}
<div
class="cmdk"
class:active={entered}
role="presentation"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div
class="cmdk__panel"
role="dialog"
aria-modal="true"
aria-label={t('cmdk.title', 'Command palette')}
>
<div class="cmdk__search">
<Icon name="search" />
<!-- svelte-ignore a11y_autofocus -->
<input
bind:this={input}
bind:value={query}
onkeydown={onListKey}
placeholder={t('cmdk.placeholder', 'Type a command or search…')}
autocomplete="off"
autofocus
/>
</div>
{#if filtered.length === 0}
<p class="cmdk__empty">{t('cmdk.no_results', 'No matching commands')}</p>
{:else}
<ul class="cmdk__list" role="listbox" bind:this={listEl}>
{#each filtered as cmd, i (cmd.id)}
<li>
<button
class="cmdk__item"
class:active={i === index}
role="option"
aria-selected={i === index}
onmouseenter={() => (index = i)}
onclick={cmd.run}
>
<Icon name={cmd.icon} />
<span class="cmdk__label">{cmd.label}</span>
{#if cmd.hint}<span class="cmdk__hint">{cmd.hint}</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
<style>
.cmdk {
position: fixed;
inset: 0;
z-index: 1200;
background: var(--color-overlay, var(--color-overlay-light));
backdrop-filter: blur(2px);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
opacity: 0;
transition: opacity var(--motion-base) var(--ease-standard);
}
.cmdk.active {
opacity: 1;
}
.cmdk__panel {
width: min(560px, 92vw);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-2xl);
overflow: hidden;
transform: translateY(-8px) scale(0.98);
transition: transform var(--motion-base) var(--ease-standard);
}
.cmdk.active .cmdk__panel {
transform: none;
}
.cmdk__search {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
}
.cmdk__search input {
flex: 1;
border: none;
background: none;
color: var(--color-text);
font-size: 1rem;
outline: none;
}
.cmdk__list {
list-style: none;
margin: 0;
padding: 0.25rem;
max-height: 50vh;
overflow: auto;
}
.cmdk__item {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.55rem 0.7rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
text-align: left;
border-radius: var(--radius-sm);
}
.cmdk__item.active {
background: var(--color-accent-bg-sm);
}
.cmdk__item.active :global(.oxi-icon) {
color: var(--color-accent);
}
.cmdk__label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cmdk__hint {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-muted);
}
.cmdk__empty {
padding: 1.5rem;
text-align: center;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,118 @@
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
// Local input value for prompt dialogs; reset whenever a new prompt opens.
let value = $state('');
let lastId: object | null = null;
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
const c = dialogs.current;
if (c && c !== lastId) {
lastId = c;
value = c.kind === 'prompt' ? (c.opts.defaultValue ?? '') : '';
if (c.kind === 'prompt' && c.opts.selectOnOpen) {
const select = c.opts.selectOnOpen;
requestAnimationFrame(() => {
const el = inputEl;
if (!el) return;
el.focus();
if (select === 'name') {
// Select the filename stem only — leave the extension
// untouched so a rename replaces just the name.
const dot = value.lastIndexOf('.');
const end = dot > 0 ? dot : value.length;
el.setSelectionRange(0, end);
} else {
el.select();
}
});
}
}
});
const open = $derived(dialogs.current !== null);
function submit(e?: SubmitEvent) {
e?.preventDefault();
const c = dialogs.current;
if (!c || dialogs.busy) return;
// `resolve` runs any async action and keeps the dialog open on failure.
if (c.kind === 'prompt') void dialogs.resolve(value);
else void dialogs.resolve(true);
}
</script>
{#if dialogs.current}
{@const c = dialogs.current}
<Modal {open} title={c.opts.title} onclose={() => dialogs.cancel()}>
{#if c.kind === 'prompt'}
<form id="dialog-form" onsubmit={submit}>
{#if c.opts.message}<p class="dlg-msg">{c.opts.message}</p>{/if}
<input
class="dlg-input"
type="text"
bind:this={inputEl}
bind:value
placeholder={c.opts.placeholder ?? ''}
autocomplete="off"
disabled={dialogs.busy}
/>
</form>
{:else if c.opts.message}
<p class="dlg-msg">{c.opts.message}</p>
{/if}
{#if dialogs.error}
<p class="dlg-error" role="alert">{dialogs.error}</p>
{/if}
{#snippet footer()}
<button class="btn btn-secondary" disabled={dialogs.busy} onclick={() => dialogs.cancel()}>
{c.opts.cancelText ?? t('common.cancel', 'Cancel')}
</button>
{#if c.kind === 'prompt'}
<button class="btn btn-primary" type="submit" form="dialog-form" disabled={dialogs.busy}>
{dialogs.busy
? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
</button>
{:else}
<button
class="btn {c.opts.danger ? 'btn-danger' : 'btn-primary'}"
disabled={dialogs.busy}
onclick={() => dialogs.resolve(true)}
>
{dialogs.busy
? t('common.loading', 'Loading…')
: (c.opts.confirmText ?? t('common.ok', 'OK'))}
</button>
{/if}
{/snippet}
</Modal>
{/if}
<style>
.dlg-msg {
margin: 0 0 var(--space-3);
color: var(--color-text);
}
.dlg-input {
width: 100%;
padding: var(--space-2-5) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-base);
}
.dlg-error {
margin: var(--space-3) 0 0;
color: var(--color-danger-text);
font-size: var(--text-sm);
}
</style>
@@ -1,79 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
interface Props {
name: string;
iconClass?: string;
subtitle?: string;
date?: string;
actions?: Snippet;
}
let { name, iconClass, subtitle, date, actions }: Props = $props();
</script>
<li class="row">
<span class="row__icon"><Icon name={iconNameFromClass(iconClass)} /></span>
<span class="row__main">
<span class="row__name" title={name}>{name}</span>
{#if subtitle}<span class="row__sub" title={subtitle}>{subtitle}</span>{/if}
</span>
{#if date}<span class="row__date">{date}</span>{/if}
{#if actions}<span class="row__actions">{@render actions()}</span>{/if}
</li>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
}
.row:hover {
background: var(--color-bg-hover);
}
.row__icon {
font-size: 1.25rem;
color: var(--color-text-muted);
flex: none;
}
.row__main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.row__sub {
font-size: 0.8125rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row__date {
font-size: 0.8125rem;
color: var(--color-text-muted);
flex: none;
}
.row__actions {
display: flex;
gap: 0.25rem;
flex: none;
}
</style>
@@ -0,0 +1,401 @@
<script lang="ts">
import { apiFetch } from '$lib/api/client';
import { fileDownloadUrl, fileInlineUrl } from '$lib/api/endpoints/files';
import { canEditWithWopi } from '$lib/api/endpoints/wopi';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import WopiEditor from '$lib/components/WopiEditor.svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
open: boolean;
file: FileItem | null;
/** Emitted when the viewer (or its embedded editor) closes, so the
* consumer can refresh the file list to pick up saves. */
onrefresh?: () => void;
}
let { open = $bindable(false), file, onrefresh }: Props = $props();
type Kind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'other';
const IMAGE_EXTS = [
'jpg',
'jpeg',
'png',
'gif',
'svg',
'webp',
'bmp',
'ico',
'heic',
'heif',
'avif',
'tiff'
];
let textContent = $state('');
let textLoading = $state(false);
let wopiOpen = $state(false);
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);
function isImage(f: FileItem): boolean {
const m = (f.mime_type ?? '').toLowerCase();
const ext = (f.name || '').split('.').pop()?.toLowerCase() ?? '';
return m.startsWith('image/') || IMAGE_EXTS.includes(ext);
}
function kindOf(f: FileItem): Kind {
const m = (f.mime_type ?? '').toLowerCase();
if (isImage(f)) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m === 'application/pdf') return 'pdf';
if (
m.startsWith('text/') ||
m === 'application/json' ||
m === 'application/xml' ||
m === 'application/javascript'
)
return 'text';
return 'other';
}
const kind = $derived(file ? kindOf(file) : 'other');
function close() {
open = false;
textContent = '';
zoom = 1;
pdfFallback = false;
onrefresh?.();
}
function onKeydown(e: KeyboardEvent) {
if (open && !wopiOpen && e.key === 'Escape') close();
}
function zoomBy(factor: number) {
zoom = Math.max(0.1, Math.min(5, zoom * factor));
}
function resetZoom() {
zoom = 1;
}
// Load text content + decide editability/auto-open whenever the file changes.
$effect(() => {
if (!open || !file) return;
const f = file;
canEdit = false;
zoom = 1;
pdfFallback = false;
const k = kindOf(f);
// Office docs (WOPI-editable, non-image) open straight in the editor
// rather than showing "No preview available" with an extra Edit click.
// Images never route through WOPI even if an editor claims the ext.
if (k === 'other' && !isImage(f)) {
void canEditWithWopi(f.name).then((v) => {
canEdit = v;
if (v && file === f && open) wopiOpen = true;
});
} else {
void canEditWithWopi(f.name).then((v) => (canEdit = v));
}
if (k === 'text') {
textLoading = true;
textContent = '';
apiFetch(fileInlineUrl(f.id), { credentials: 'same-origin' })
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((txt) => (textContent = txt.slice(0, 500_000)))
.catch(() => (textContent = t('files.preview_failed', 'Could not load preview.')))
.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).
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);
}
});
</script>
<svelte:window onkeydown={onKeydown} />
{#if open && file}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fv"
role="dialog"
aria-modal="true"
aria-label={file.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div class="fv__panel">
<header class="fv__bar">
<span class="fv__title">{file.name}</span>
<div class="fv__actions">
{#if kind === 'image'}
<div class="fv__zoom" role="group" aria-label={t('viewer.zoom', 'Zoom')}>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_out', 'Zoom out')}
aria-label={t('viewer.zoom_out', 'Zoom out')}
onclick={() => zoomBy(0.8)}
>
<Icon name="search-minus" />
</button>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_reset', 'Reset zoom')}
aria-label={t('viewer.zoom_reset', 'Reset zoom')}
onclick={resetZoom}
>
<Icon name="expand" />
</button>
<button
class="fv__zoom-btn"
title={t('viewer.zoom_in', 'Zoom in')}
aria-label={t('viewer.zoom_in', 'Zoom in')}
onclick={() => zoomBy(1.2)}
>
<Icon name="search-plus" />
</button>
</div>
{/if}
{#if canEdit}
<button class="btn btn-primary btn-sm" onclick={() => (wopiOpen = true)}>
<Icon name="pen" />
{t('files.edit', 'Edit')}
</button>
{/if}
<a class="btn btn-secondary btn-sm" href={fileDownloadUrl(file.id)} download>
<Icon name="download" />
{t('common.download', 'Download')}
</a>
<a
class="btn btn-secondary btn-sm"
href={fileInlineUrl(file.id)}
target="_blank"
rel="noreferrer"
>
<Icon name="external-link-alt" />
</a>
<button class="fv__close" aria-label={t('common.close', 'Close')} onclick={close}>
<Icon name="times" />
</button>
</div>
</header>
<div class="fv__body">
{#if kind === 'image'}
<img
class="fv__media fv__image"
src={fileInlineUrl(file.id)}
alt={file.name}
style:transform="scale({zoom})"
/>
{:else if kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="fv__media" src={fileInlineUrl(file.id)} controls preload="metadata"></video>
{: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}
>
<p>{t('files.preview_failed', 'Could not load preview.')}</p>
</object>
{/if}
{:else if kind === 'text'}
{#if textLoading}
<p class="fv__status">{t('common.loading', 'Loading…')}</p>
{:else}
<pre class="fv__text">{textContent}</pre>
{/if}
{:else}
<div class="fv__status fv__status--center">
<Icon name="file" class="fv__big-icon" />
<p>{t('files.no_preview', 'No preview available for this file type.')}</p>
</div>
{/if}
</div>
</div>
</div>
<WopiEditor
bind:open={wopiOpen}
fileId={file.id}
fileName={file.name}
action="edit"
onclose={() => {
onrefresh?.();
// If the editor was auto-opened for an Office doc, closing it should
// dismiss the whole viewer (there's nothing to preview behind it).
if (kind === 'other') close();
}}
/>
{/if}
<style>
.fv {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-overlay, var(--color-overlay-heavy));
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.fv__panel {
display: flex;
flex-direction: column;
width: min(1100px, 100%);
height: min(90vh, 100%);
background: var(--color-bg-surface);
border-radius: var(--radius-lg, var(--radius-md));
overflow: hidden;
}
.fv__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--color-border);
}
.fv__title {
font-weight: var(--weight-semibold, 600);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-heading);
}
.fv__actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.fv__close {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
padding: 0.25rem 0.5rem;
}
.fv__zoom {
display: inline-flex;
align-items: center;
gap: 0.15rem;
margin-right: 0.3rem;
}
.fv__zoom-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.fv__zoom-btn:hover {
background: var(--color-bg-hover);
}
.fv__image {
transition: transform 0.12s ease;
}
.fv__body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
background: var(--color-bg-muted);
}
.fv__media {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.fv__audio {
width: min(600px, 90%);
}
.fv__pdf {
width: 100%;
height: 100%;
}
.fv__text {
width: 100%;
height: 100%;
margin: 0;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, monospace);
font-size: var(--text-sm);
color: var(--color-text);
background: var(--color-bg-surface);
}
.fv__status {
color: var(--color-text-muted);
}
.fv__status--center {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
:global(.fv__big-icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,125 @@
<script lang="ts" module>
/** A group-by dimension shown in the toolbar's popup menu. */
export interface GroupOption {
key: string;
label: string;
/** Optional glyph for the menu option (defaults to the group glyph). */
icon?: string;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
interface Props {
/** Group-by dimensions; omit/empty to hide the group-by control. */
groups?: GroupOption[];
/** Active group-by key (controlled by the parent). */
groupBy?: string;
/** Whether the sort direction is reversed (controlled by the parent). */
reversed?: boolean;
/** Fired when a group-by dimension is chosen. */
ongroup?: (key: string) => void;
/** Fired when the sort-direction toggle is clicked. */
ondirection?: () => void;
/** Show the grid/list view toggle (default true). */
showViewToggle?: boolean;
/** Left-hand actions (upload/new-folder/empty-trash/batch bar, …). */
start?: Snippet;
}
let {
groups,
groupBy = '',
reversed = false,
ongroup,
ondirection,
showViewToggle = true,
start
}: Props = $props();
// The group-by button always reflects the active dimension (default = first).
const active = $derived(groups?.find((g) => g.key === groupBy) ?? groups?.[0]);
let menuOpen = $state(false);
// Close the popup on outside click.
$effect(() => {
if (!menuOpen) return;
const onDown = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest('.group-by-selector')) menuOpen = false;
};
window.addEventListener('pointerdown', onDown);
return () => window.removeEventListener('pointerdown', onDown);
});
function pick(key: string) {
menuOpen = false;
ongroup?.(key);
}
</script>
<div class="actions-bar">
{#if start}{@render start()}{:else}<div class="action-buttons"></div>{/if}
{#if groups?.length || showViewToggle}
<div class="view-toggle" role="group" aria-label={t('view.label', 'View options')}>
{#if groups?.length}
<div class="group-by-selector">
<button
class="toggle-btn group-by-btn active"
title={t('groupby.title', 'Group by')}
aria-haspopup="true"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<Icon name={active?.icon ?? 'layer-group'} />
<span class="group-by-label">{active?.label ?? ''}</span>
</button>
<button
class="toggle-btn sort-dir-btn"
class:active={reversed}
title={t('sortdir.title', 'Sort direction')}
aria-label={t('sort.direction', 'Sort direction')}
onclick={() => ondirection?.()}
>
<Icon name="arrow-up" />
</button>
{#if menuOpen}
<div class="group-by-menu">
{#each groups as g (g.key)}
<button
class="group-by-option"
class:active={groupBy === g.key}
onclick={() => pick(g.key)}
>
<Icon name={g.icon ?? 'layer-group'} />
{g.label}
</button>
{/each}
</div>
{/if}
</div>
{#if showViewToggle}<span class="view-toggle-separator"></span>{/if}
{/if}
{#if showViewToggle}
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'grid'}
title={t('view.grid', 'Grid view')}
aria-pressed={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}><Icon name="th" /></button
>
<button
class="toggle-btn"
class:active={filesStore.viewMode === 'list'}
title={t('view.list', 'List view')}
aria-pressed={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}><Icon name="list" /></button
>
{/if}
</div>
{/if}
</div>
+87 -3
View File
@@ -12,14 +12,60 @@
let { open = $bindable(false), title, onclose, children, footer }: Props = $props();
let dialogEl = $state<HTMLElement | null>(null);
let prevFocus: HTMLElement | null = null;
function close() {
open = false;
onclose?.();
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
const FOCUSABLE =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
function focusables(): HTMLElement[] {
if (!dialogEl) return [];
return Array.from(dialogEl.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null || el === document.activeElement
);
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
close();
return;
}
// Focus trap: keep Tab cycling inside the dialog.
if (e.key === 'Tab') {
const items = focusables();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
}
// On open: remember the previously focused element and move focus into the
// dialog. On close: restore focus so keyboard users aren't dumped at <body>.
$effect(() => {
if (open) {
prevFocus = (document.activeElement as HTMLElement | null) ?? null;
requestAnimationFrame(() => {
const items = focusables();
(items[0] ?? dialogEl)?.focus();
});
} else if (prevFocus) {
prevFocus.focus();
prevFocus = null;
}
});
</script>
<svelte:window onkeydown={open ? onkeydown : undefined} />
@@ -33,7 +79,14 @@
if (e.target === e.currentTarget) close();
}}
>
<div class="modal" role="dialog" aria-modal="true" aria-label={title}>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
bind:this={dialogEl}
>
{#if title}
<header class="modal__header">
<h2 class="modal__title">{title}</h2>
@@ -62,6 +115,7 @@
justify-content: center;
z-index: 900;
padding: 1rem;
animation: modal-fade 0.16s ease;
}
.modal {
@@ -74,6 +128,7 @@
overflow: auto;
display: flex;
flex-direction: column;
animation: modal-pop 0.18s ease;
}
.modal__header {
@@ -109,4 +164,33 @@
padding: 1rem 1.25rem;
border-top: 1px solid var(--color-border);
}
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.modal__backdrop,
.modal {
animation: none;
}
}
</style>
@@ -0,0 +1,273 @@
<script lang="ts">
import { listFolder, moveFolder } from '$lib/api/endpoints/folders';
import { moveFile } from '$lib/api/endpoints/files';
import { copyFiles, copyFolders } from '$lib/api/endpoints/batch';
import type { FolderItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Target {
id: string;
name: string;
kind: 'file' | 'folder';
}
interface Props {
open: boolean;
item: Target | null;
/** Optional multi-item batch; takes precedence over `item`. */
items?: Target[] | null;
/** 'move' (default) relocates; 'copy' duplicates into the picked folder. */
mode?: 'move' | 'copy';
onmoved?: () => void;
}
let { open = $bindable(false), item, items = null, mode = 'move', onmoved }: Props = $props();
const targets = $derived(items && items.length ? items : item ? [item] : []);
const targetIds = $derived(new Set(targets.map((x) => x.id)));
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let folders = $state<FolderItem[]>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
let working = $state(false);
async function loadInto(id: string) {
loading = true;
try {
currentId = id;
folders = (await listFolder(id)).folders;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
loading = false;
}
}
async function init() {
const home = await session.loadHomeFolder();
if (!home) return;
crumbs = [{ id: home, name: session.homeFolderName ?? t('nav.files', 'Files') }];
await loadInto(home);
}
function enter(f: FolderItem) {
crumbs = [...crumbs, { id: f.id, name: f.name }];
void loadInto(f.id);
}
function gotoCrumb(index: number) {
crumbs = crumbs.slice(0, index + 1);
void loadInto(crumbs[index].id);
}
/** Jump to the home (root) folder — the first crumb. */
function goHome() {
if (crumbs.length) gotoCrumb(0);
}
/** Step up one level to the parent folder (no-op at home). */
function goParent() {
if (crumbs.length > 1) gotoCrumb(crumbs.length - 2);
}
const atHome = $derived(crumbs.length <= 1);
async function confirmMove() {
if (!targets.length || !currentId) return;
working = true;
try {
if (mode === 'copy') {
const fileIds = targets.filter((x) => x.kind === 'file').map((x) => x.id);
const folderIds = targets.filter((x) => x.kind === 'folder').map((x) => x.id);
await copyFiles(fileIds, currentId);
await copyFolders(folderIds, currentId);
ui.notify(t('files.copied', 'Copied'), 'success');
} else {
for (const tgt of targets) {
if (tgt.id === currentId) continue;
if (tgt.kind === 'file') await moveFile(tgt.id, currentId);
else await moveFolder(tgt.id, currentId);
}
ui.notify(t('files.moved', 'Moved'), 'success');
}
open = false;
onmoved?.();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
working = false;
}
}
// (Re)initialise the picker each time it opens.
$effect(() => {
if (open && targets.length) void init();
});
const moveTitle = $derived.by(() => {
if (mode === 'copy') {
return targets.length > 1
? t('files.copy_n', { n: targets.length }, 'Copy {{n}} items')
: t('files.copy_title', { name: targets[0]?.name ?? '' }, 'Copy “{{name}}”');
}
return targets.length > 1
? t('files.move_n', { n: targets.length }, 'Move {{n}} items')
: t('files.move_title', { name: targets[0]?.name ?? '' }, 'Move “{{name}}”');
});
</script>
<Modal bind:open title={moveTitle}>
<div class="mv-nav">
<button
class="mv-nav-btn"
title={t('breadcrumb.home', 'Home')}
aria-label={t('breadcrumb.home', 'Home')}
disabled={atHome}
onclick={goHome}><Icon name="home" /></button
>
<button
class="mv-nav-btn"
title={t('dialogs.go_to_parent', 'Go to parent')}
aria-label={t('dialogs.go_to_parent', 'Go to parent')}
disabled={atHome}
onclick={goParent}><Icon name="level-up-alt" /></button
>
<nav class="mv-crumbs" aria-label="Breadcrumb">
{#each crumbs as c, i (c.id)}
{#if i > 0}<span class="mv-sep">/</span>{/if}
<button class="mv-crumb" onclick={() => gotoCrumb(i)}>{c.name}</button>
{/each}
</nav>
</div>
{#if loading}
<p class="mv-status">{t('common.loading', 'Loading…')}</p>
{:else if folders.length === 0}
<p class="mv-status">{t('files.no_subfolders', 'No subfolders here.')}</p>
{:else}
<ul class="mv-list">
{#each folders as f (f.id)}
<li>
<button class="mv-folder" disabled={targetIds.has(f.id)} onclick={() => enter(f)}>
<Icon name="folder" /> <span>{f.name}</span>
<Icon name="chevron-right" class="mv-enter" />
</button>
</li>
{/each}
</ul>
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
{t('common.cancel', 'Cancel')}
</button>
<button class="btn btn-primary" disabled={working || !currentId} onclick={confirmMove}>
{mode === 'copy' ? t('files.copy_here', 'Copy here') : t('files.move_here', 'Move here')}
</button>
{/snippet}
</Modal>
<style>
.mv-nav {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.mv-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
cursor: pointer;
flex: none;
}
.mv-nav-btn:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.mv-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mv-crumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
}
.mv-crumb {
background: none;
border: none;
color: var(--color-accent-text, var(--color-primary));
cursor: pointer;
padding: 0.125rem 0.25rem;
}
.mv-sep {
color: var(--color-text-muted);
}
.mv-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 50vh;
overflow: auto;
}
.mv-folder {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
text-align: left;
}
.mv-folder:hover:not(:disabled) {
background: var(--color-bg-hover);
}
.mv-folder:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mv-folder span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.mv-enter) {
color: var(--color-text-muted);
}
.mv-status {
color: var(--color-text-muted);
padding: 1rem 0;
text-align: center;
}
</style>
@@ -0,0 +1,709 @@
<script lang="ts" module>
import type { ItemType } from '$lib/api/types';
/** Normalised row passed to ResourceList; views map their items to this. */
export interface ResourceEntry {
id: string;
name: string;
kind: ItemType;
iconClass?: string;
path?: string | null;
size?: number | null;
date?: number | string | null;
typeLabel?: string;
/** Owner user id — enables the owner column + vignette when `showOwner`. */
ownerId?: string | null;
/** Owner display name (resolved by the page). */
ownerName?: string | null;
/** Per-entry favorite state for the star-toggle widget. */
isFavorite?: boolean;
/** Stable category key (Folder / Image / …) used by the `type` group-by. */
category?: string;
/** Modified timestamp (epoch seconds/ms or ISO) for the `modifiedAt` group-by. */
modifiedAt?: number | string | null;
}
/**
* A group-by ("swimlane") dimension a page can offer. `orderBy` is sent to the
* API; the optional `bucketOf` maps an entry to a section key, and `labelOf`
* maps that key to a header label. Omitting `bucketOf` means a flat list.
*/
export interface GroupByDef {
key: string;
label: string;
orderBy: string;
/** Optional icon for the dropdown option (defaults to the group glyph). */
icon?: string;
bucketOf?: (entry: ResourceEntry) => string | null;
labelOf?: (bucketKey: string) => string;
}
/** A right-click / overflow context-menu action. */
export interface ContextAction {
key: string;
label: string;
icon: string;
danger?: boolean;
run: (entry: ResourceEntry) => void;
}
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
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';
interface Props {
title: string;
items: ResourceEntry[];
loading?: boolean;
error?: string | null;
/** Empty-state primary line. */
emptyText?: string;
/** Empty-state secondary hint line. */
emptyHint?: string;
/** Empty-state icon-registry name (e.g. "star", "clock", "trash"). */
emptyIcon?: string;
hasMore?: boolean;
onloadmore?: () => void;
/** Show the path/location column (list view only). */
showPath?: boolean;
/** Override the path column header label (e.g. trash → "Original location"). */
pathLabel?: string;
showSize?: boolean;
showType?: boolean;
showDate?: boolean;
/** Override the date column header label (e.g. trash → "Remaining"). */
dateLabel?: string;
/** Custom renderer for the date cell (e.g. trash expiry chip). */
dateCell?: Snippet<[ResourceEntry]>;
/** Show the owner column + vignette (list view) and hover tooltip. */
showOwner?: boolean;
/** Allow grid/list toggle (shares the app-wide view mode). */
showViewToggle?: boolean;
/** Multi-select checkboxes + selection model. */
selectable?: boolean;
/** Right-click / overflow context-menu actions. */
contextActions?: ContextAction[];
/** Group-by dimensions; when provided, a swimlane selector is shown. */
groupBys?: GroupByDef[];
/** Active group-by key (bind:groupBy from the page). */
groupBy?: string;
/** Reverse sort toggle state (bind:reversed from the page). */
reversed?: boolean;
/** Called when group-by or direction changes; page should reload page 1. */
onreload?: (orderBy: string, reversed: boolean) => void;
onopen?: (entry: ResourceEntry) => void;
/** Per-entry favorite star toggle. */
onfavorite?: (entry: ResourceEntry) => void;
/** Selection changed (set of selected entry ids). */
onselectionchange?: (ids: Set<string>) => void;
actions?: Snippet<[ResourceEntry]>;
toolbar?: Snippet;
/** Batch toolbar shown when items are selected; receives selected entries. */
batchToolbar?: Snippet<[ResourceEntry[]]>;
}
let {
title,
items,
loading = false,
error = null,
emptyText,
emptyHint,
emptyIcon,
hasMore = false,
onloadmore,
showPath = true,
pathLabel,
showSize = true,
showType = false,
showDate = true,
dateLabel,
dateCell,
showOwner = false,
showViewToggle = true,
selectable = false,
contextActions,
groupBys,
groupBy = $bindable(''),
reversed = $bindable(false),
onreload,
onopen,
onfavorite,
onselectionchange,
actions,
toolbar,
batchToolbar
}: Props = $props();
const isEmpty = $derived(items.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
// Build the list-view column track from the enabled cells.
const columns = $derived(
[
selectable ? '36px' : '',
'minmax(200px, 2fr)',
showOwner ? 'minmax(120px, 1fr)' : '',
showPath ? 'minmax(140px, 1.5fr)' : '',
showType ? '120px' : '',
showSize ? '110px' : '',
showDate ? '160px' : '',
actions ? '120px' : ''
]
.filter(Boolean)
.join(' ')
);
const SKELETON = [0, 1, 2, 3, 4, 5];
// ── Group-by / direction ──────────────────────────────────────────────────
const activeGroup = $derived(groupBys?.find((g) => g.key === groupBy));
function selectGroup(key: string) {
if (groupBy === key) return;
groupBy = key;
const def = groupBys?.find((g) => g.key === key);
onreload?.(def?.orderBy ?? 'name', reversed);
}
function toggleDirection() {
reversed = !reversed;
onreload?.(activeGroup?.orderBy ?? 'name', reversed);
}
/**
* Partition the visible items into grouped sections when a `bucketOf` is
* active. Server order is preserved within and across buckets (first-seen).
*/
const sections = $derived.by((): Array<{ key: string; label: string; rows: ResourceEntry[] }> => {
const bucketOf = activeGroup?.bucketOf;
if (!bucketOf) return [{ key: '', label: '', rows: items }];
const order: string[] = [];
const map = new Map<string, ResourceEntry[]>();
for (const entry of items) {
const k = bucketOf(entry) ?? '∅';
if (!map.has(k)) {
map.set(k, []);
order.push(k);
}
map.get(k)!.push(entry);
}
return order.map((k) => ({
key: k,
label: activeGroup?.labelOf?.(k) ?? k,
rows: map.get(k)!
}));
});
const grouped = $derived(!!activeGroup?.bucketOf);
// ── Selection ─────────────────────────────────────────────────────────────
let selected = $state<Set<string>>(new Set());
function toggleSelected(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
selected = next;
onselectionchange?.(next);
}
function clearSelection() {
selected = new Set();
onselectionchange?.(selected);
}
const allSelected = $derived(items.length > 0 && selected.size === items.length);
function toggleSelectAll() {
if (allSelected) clearSelection();
else {
selected = new Set(items.map((i) => i.id));
onselectionchange?.(selected);
}
}
const selectedEntries = $derived(items.filter((i) => selected.has(i.id)));
// Drop selection ids that are no longer present after a reload.
$effect(() => {
const ids = new Set(items.map((i) => i.id));
let changed = false;
const next = new Set<string>();
for (const id of selected) {
if (ids.has(id)) next.add(id);
else changed = true;
}
if (changed) {
selected = next;
onselectionchange?.(next);
}
});
// ── Right-click context menu ──────────────────────────────────────────────
let ctxOpen = $state(false);
let ctxX = $state(0);
let ctxY = $state(0);
let ctxEntry = $state<ResourceEntry | null>(null);
function openContext(e: MouseEvent, entry: ResourceEntry) {
if (!contextActions?.length) return;
e.preventDefault();
e.stopPropagation();
ctxEntry = entry;
ctxX = Math.min(e.clientX, window.innerWidth - 220);
ctxY = Math.min(e.clientY, window.innerHeight - (contextActions.length * 44 + 24));
ctxOpen = true;
}
function closeContext() {
ctxOpen = false;
ctxEntry = null;
}
// ── Infinite scroll (IntersectionObserver) ────────────────────────────────
let sentinel = $state<HTMLElement | null>(null);
$effect(() => {
const el = sentinel;
if (!el || typeof IntersectionObserver === 'undefined') return;
const obs = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (en.isIntersecting && hasMore && !loading) onloadmore?.();
}
},
{ rootMargin: '200px' }
);
obs.observe(el);
return () => obs.disconnect();
});
function ownerTitle(entry: ResourceEntry): string {
const owner = entry.ownerName ?? entry.ownerId ?? '';
const path = entry.path ?? '';
return [
owner && `${t('files.col_owner', 'Owner')}: ${owner}`,
path && `${t('files.col_path', 'Location')}: ${path}`
]
.filter(Boolean)
.join('\n');
}
</script>
{#snippet row(entry: ResourceEntry)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
class="file-item"
class:file-item--selected={selectable && selected.has(entry.id)}
role={onopen ? 'button' : undefined}
tabindex={onopen ? 0 : undefined}
title={showOwner ? ownerTitle(entry) : undefined}
onclick={onopen ? () => onopen(entry) : undefined}
onkeydown={onopen ? (e) => e.key === 'Enter' && onopen(entry) : undefined}
oncontextmenu={contextActions?.length ? (e) => openContext(e, entry) : undefined}
>
{#if selectable}
<div class="select-cell" role="presentation" onclick={(e) => e.stopPropagation()}>
<input
type="checkbox"
aria-label={t('common.select', 'Select')}
checked={selected.has(entry.id)}
onchange={() => toggleSelected(entry.id)}
/>
</div>
{/if}
<div class="name-cell">
<span class="file-icon">
<Icon name={entry.kind === 'folder' ? 'folder' : iconNameFromClass(entry.iconClass)} />
</span>
<span class="name-cell__text">{entry.name}</span>
</div>
{#if showOwner}
<div class="owner-cell">
<span class="rl-vignette">
<span class="rl-vignette__avatar" aria-hidden="true"
>{(entry.ownerName ?? '?').slice(0, 1).toUpperCase()}</span
>
<span class="rl-vignette__name">{entry.ownerName ?? entry.ownerId ?? ''}</span>
</span>
</div>
{/if}
{#if showPath}<div class="path-cell">{entry.path ?? ''}</div>{/if}
{#if showType}<div class="type-cell">{entry.typeLabel ?? ''}</div>{/if}
{#if showSize}
<div class="size-cell">{entry.size != null ? formatBytes(entry.size) : '—'}</div>
{/if}
{#if showDate}
<div class="date-cell">
{#if dateCell}{@render dateCell(entry)}{:else}{formatDate(entry.date)}{/if}
</div>
{/if}
<div class="grid-meta">
{#if showDate && dateCell}<span class="grid-meta__chip">{@render dateCell(entry)}</span>{/if}
<span class="grid-meta__line">
{#if entry.size != null}<span class="grid-meta__size">{formatBytes(entry.size)}</span>{/if}
{#if entry.date != null}<span class="grid-meta__date">{formatDate(entry.date)}</span>{/if}
</span>
</div>
{#if onfavorite}
<button
class="rl-star"
class:rl-star--on={entry.isFavorite}
title={entry.isFavorite
? t('files.unfavorite', 'Remove favorite')
: t('files.favorite', 'Add favorite')}
aria-pressed={!!entry.isFavorite}
onclick={(e) => {
e.stopPropagation();
onfavorite(entry);
}}><Icon name={entry.isFavorite ? 'star' : 'star-outline'} /></button
>
{/if}
{#if actions}
<div class="action-cell">{@render actions(entry)}</div>
{/if}
</div>
{/snippet}
<div class="page-sticky-header">
<h1 class="page-title">{title}</h1>
<ListToolbar
groups={groupBys}
{groupBy}
{reversed}
ongroup={selectGroup}
ondirection={toggleDirection}
{showViewToggle}
>
{#snippet start()}
<div class="action-buttons">{@render toolbar?.()}</div>
{/snippet}
</ListToolbar>
</div>
{#if selectable && selected.size > 0 && batchToolbar}
<div class="rl-batch" role="region" aria-label={t('files.selection', 'Selection')}>
<button class="rl-batch__close" title={t('common.clear', 'Clear')} onclick={clearSelection}>
<Icon name="times" />
</button>
<span class="rl-batch__count"
>{t('files.selected_count', { count: selected.size }, '{{count}} selected')}</span
>
<div class="rl-batch__actions">{@render batchToolbar(selectedEntries)}</div>
</div>
{/if}
{#if error}
<div class="empty-state">
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
<p>{error}</p>
</div>
{:else if loading && isEmpty}
<div class="files-container">
<div
class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}
>
{#each SKELETON as i (i)}
{#if filesStore.viewMode === 'grid'}
<div class="skeleton-card">
<div class="skeleton skeleton-thumb"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>
</div>
{:else}
<div class="skeleton-row">
<div class="skeleton skeleton-icon"></div>
<div class="skeleton skeleton-line skeleton-line--medium"></div>
<div class="skeleton skeleton-line skeleton-line--short"></div>
</div>
{/if}
{/each}
</div>
</div>
{:else if isEmpty}
<div class="empty-state">
{#if emptyIcon}<Icon name={emptyIcon} class="empty-state-icon" />{/if}
<p>{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
{#if emptyHint}<p class="empty-state__hint">{emptyHint}</p>{/if}
</div>
{:else}
<div class="files-container">
<div class={viewClass} style="--files-list-columns: {columns}">
<div class="list-header">
{#if selectable}
<div class="select-cell">
<input
type="checkbox"
aria-label={t('common.select_all', 'Select all')}
checked={allSelected}
onchange={toggleSelectAll}
/>
</div>
{/if}
<div>{t('files.col_name', 'Name')}</div>
{#if showOwner}<div>{t('files.col_owner', 'Owner')}</div>{/if}
{#if showPath}<div>{pathLabel ?? t('files.col_path', 'Location')}</div>{/if}
{#if showType}<div>{t('files.col_type', 'Type')}</div>{/if}
{#if showSize}<div>{t('files.col_size', 'Size')}</div>{/if}
{#if showDate}<div>{dateLabel ?? t('files.col_modified', 'Date')}</div>{/if}
{#if onfavorite || actions}<div></div>{/if}
</div>
{#if grouped}
{#each sections as section (section.key)}
<div class="rl-swimlane-header" role="rowheader">{section.label}</div>
{#each section.rows as entry (entry.id)}
{@render row(entry)}
{/each}
{/each}
{:else}
{#each items as entry (entry.id)}
{@render row(entry)}
{/each}
{/if}
</div>
{#if hasMore}
<button class="btn btn-secondary rl-more" onclick={onloadmore} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
<!-- Infinite-scroll sentinel: auto-loads the next page as it nears the viewport. -->
<div bind:this={sentinel} class="rl-sentinel" aria-hidden="true"></div>
</div>
{/if}
{#if ctxOpen && ctxEntry && contextActions}
<div
class="rl-ctx-scrim"
role="presentation"
onclick={closeContext}
oncontextmenu={(e) => e.preventDefault()}
></div>
<div class="rl-ctx-menu" style:left="{ctxX}px" style:top="{ctxY}px" role="menu">
{#each contextActions as action (action.key)}
<button
class="rl-ctx-item"
class:rl-ctx-item--danger={action.danger}
role="menuitem"
onclick={() => {
const e = ctxEntry!;
closeContext();
action.run(e);
}}
>
<Icon name={action.icon} />
{action.label}
</button>
{/each}
</div>
{/if}
<style>
.rl-more {
margin: var(--space-4) auto 0;
}
.rl-sentinel {
height: 1px;
width: 100%;
}
/* ── Batch toolbar ── */
.rl-batch {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
margin-bottom: var(--space-3);
background: var(--color-accent-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.rl-batch__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.rl-batch__close:hover {
background: var(--color-bg-hover);
}
.rl-batch__count {
font-weight: var(--weight-semibold);
color: var(--color-text);
}
.rl-batch__actions {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
/* ── Selection column ── */
.select-cell {
display: flex;
align-items: center;
justify-content: center;
}
.file-item--selected {
background: var(--color-accent-bg);
}
/* ── Owner vignette ── */
.owner-cell {
display: flex;
align-items: center;
min-width: 0;
}
.rl-vignette {
display: inline-flex;
align-items: center;
gap: var(--space-2);
min-width: 0;
}
.rl-vignette__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--color-accent-bg-sm);
color: var(--color-accent-text);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
flex: none;
}
.rl-vignette__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text-secondary);
}
.name-cell__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Favorite star ── */
.rl-star {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
}
.rl-star:hover {
background: var(--color-bg-hover);
color: var(--color-text-secondary);
}
.rl-star--on {
color: var(--color-warning-text-amber);
}
/* ── Swimlane section header ── */
.rl-swimlane-header {
grid-column: 1 / -1;
padding: var(--space-3) var(--space-1) var(--space-1);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-faint);
}
/* Grid view date meta line. */
.grid-meta__line {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Grid view: overlay a custom date chip (e.g. trash expiry) on the card corner. */
:global(.files-grid-view) .grid-meta__chip {
position: absolute;
top: var(--space-2);
right: var(--space-2);
z-index: 1;
}
/* ── Context menu ── */
.rl-ctx-scrim {
position: fixed;
inset: 0;
z-index: 1000;
}
.rl-ctx-menu {
position: fixed;
z-index: 1001;
min-width: 200px;
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
}
.rl-ctx-item {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
cursor: pointer;
}
.rl-ctx-item:hover {
background: var(--color-bg-hover);
}
.rl-ctx-item--danger {
color: var(--color-danger-text);
}
.empty-state__hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
/* Empty/error icon lives inside the <Icon> child component's <svg>. */
.empty-state :global(.empty-state-icon) {
font-size: var(--text-5xl);
color: var(--color-text-faint);
margin-bottom: var(--space-2);
}
.empty-state :global(.empty-state-icon--error) {
color: var(--color-danger-text);
}
</style>
@@ -1,95 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
loading: boolean;
error?: string | null;
empty: boolean;
emptyText?: string;
hasMore?: boolean;
onloadmore?: () => void;
toolbar?: Snippet;
children: Snippet;
}
let {
loading,
error = null,
empty,
emptyText,
hasMore = false,
onloadmore,
toolbar,
children
}: Props = $props();
</script>
<section class="rl">
{#if toolbar}
<div class="rl__toolbar">{@render toolbar()}</div>
{/if}
{#if error}
<p class="rl__error" role="alert">{error}</p>
{:else if loading && empty}
<p class="rl__status">{t('common.loading', 'Loading…')}</p>
{:else if empty}
<p class="rl__status">{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
{:else}
<ul class="rl__list">
{@render children()}
</ul>
{#if hasMore}
<button class="rl__more" onclick={onloadmore} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
{/if}
</section>
<style>
.rl {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.rl__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rl__status,
.rl__error {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.rl__error {
color: var(--color-danger-text);
}
.rl__more {
align-self: center;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
</style>
@@ -0,0 +1,846 @@
<script lang="ts">
import {
copyShareLink,
createShare,
deleteShare,
listSharesForItem,
updateShare
} from '$lib/api/endpoints/shares';
import {
createGrant,
expiryToIso,
fetchGrantsForResource,
notifyGrantRecipient,
revokeGrant,
roleFromPermissions,
updateGrantRole,
type Grant,
type GrantSubject,
type GrantSubjectInput,
type NotifyOutcome,
type ShareRole
} from '$lib/api/endpoints/grants';
import {
ensureResolvers,
isDirectoryAvailable,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import type { ItemType, ShareItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Target {
id: string;
name: string;
kind: ItemType;
}
interface Props {
open: boolean;
item: Target | null;
}
let { open = $bindable(false), item }: Props = $props();
let tab = $state<'people' | 'link'>('people');
let directoryAvailable = $state(true);
const ROLES: { v: ShareRole; l: string; icon: string }[] = [
{ v: 'admin', l: t('share.role.canManage', 'Can manage'), icon: 'crown' },
{ v: 'editor', l: t('share.role.canEdit', 'Can edit'), icon: 'pencil-alt' },
{ v: 'viewer', l: t('share.role.canView', 'Can view'), icon: 'eye' }
];
const ROLE_ORDER: ShareRole[] = ['admin', 'editor', 'viewer'];
function roleLabel(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.l ?? r;
}
function roleIcon(r: ShareRole): string {
return ROLES.find((x) => x.v === r)?.icon ?? 'eye';
}
// ── People / grants ──────────────────────────────────────────────────────
interface Member {
subject: GrantSubject;
recipient: Recipient;
role: ShareRole;
grantIds: string[];
/** Representative grant id for notify (any grant on this subject). */
notifyGrantId?: string;
expiry: string | null; // YYYY-MM-DD or null
isExternal: boolean;
}
let members = $state<Member[]>([]);
let grantsLoading = $state(false);
let query = $state('');
let results = $state<Recipient[]>([]);
let newRole = $state<ShareRole>('viewer');
let newExpiry = $state<string | null>(null);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function isoToDate(iso: string | null | undefined): string | null {
return iso ? String(iso).slice(0, 10) : null;
}
function groupGrants(grants: Grant[]): Member[] {
const bySubject = new Map<
string,
{ subject: GrantSubject; perms: string[]; ids: string[]; expiry: string | null }
>();
for (const g of grants) {
if (g.subject.type === 'token') continue;
const key = `${g.subject.type}:${g.subject.id}`;
const entry = bySubject.get(key) ?? { subject: g.subject, perms: [], ids: [], expiry: null };
entry.perms.push(g.permission);
entry.ids.push(g.id);
if (g.expires_at && !entry.expiry) entry.expiry = isoToDate(g.expires_at);
bySubject.set(key, entry);
}
return [...bySubject.values()].map((e) => ({
subject: e.subject,
recipient: resolveRecipient(e.subject.type as 'user' | 'group', e.subject.id),
role: roleFromPermissions(e.perms),
grantIds: e.ids,
notifyGrantId: e.ids[0],
expiry: e.expiry,
isExternal: false
}));
}
async function loadGrants() {
if (!item) return;
grantsLoading = true;
try {
await ensureResolvers();
directoryAvailable = isDirectoryAvailable();
members = groupGrants(await fetchGrantsForResource(item.kind, item.id));
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
grantsLoading = false;
}
}
function onQueryInput() {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
const existing = new Set(members.map((m) => `${m.subject.type}:${m.subject.id}`));
results = (await searchRecipients(query)).filter(
(r) => !existing.has(`${r.type === 'email' ? 'user' : r.type}:${r.id}`)
);
}, 200);
}
function subjectInput(r: Recipient): GrantSubjectInput {
if (r.type === 'email') return { type: 'email', email: r.id };
return { type: r.type, id: r.id };
}
async function addRecipient(r: Recipient) {
if (!item) return;
try {
const res = await createGrant(
subjectInput(r),
{ type: item.kind, id: item.id },
newRole,
expiryToIso(newExpiry)
);
query = '';
results = [];
summarizeNotifications(res.notification.outcomes);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeRole(m: Member, role: ShareRole) {
if (!item || role === m.role) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
role,
expiryToIso(m.expiry)
);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeMemberExpiry(m: Member, expiry: string | null) {
if (!item) return;
try {
await updateGrantRole(
m.subject,
{ type: item.kind, id: item.id },
m.role,
expiryToIso(expiry)
);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeMember(m: Member) {
try {
for (const id of m.grantIds) await revokeGrant(id);
await loadGrants();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function notifyMember(m: Member) {
if (!m.notifyGrantId) return;
try {
const set = await notifyGrantRecipient(m.notifyGrantId);
summarizeNotifications(set.outcomes);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
/** Aggregate notification outcomes into a single toast (mirrors OLD _surfaceNotifySummary). */
function summarizeNotifications(outcomes: NotifyOutcome[]) {
if (!outcomes || outcomes.length === 0) return;
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rateLimited = outcomes.filter((o) => o.kind === 'rate_limited').length;
const skipped = outcomes.filter((o) => o.kind === 'not_applicable').length;
const lines: string[] = [];
if (sent > 0) lines.push(t('share.notify.sent', { n: sent }, '{{n}} notified by email.'));
if (coalesced > 0)
lines.push(t('share.notify.coalesced', { n: coalesced }, '{{n}} already notified recently.'));
if (rateLimited > 0)
lines.push(
t('share.notify.rateLimited', { n: rateLimited }, '{{n}} hit the rate limit — try later.')
);
if (skipped > 0)
lines.push(
t('share.notify.skipped', { n: skipped }, '{{n}} skipped (no email / opted out).')
);
if (lines.length === 0) return;
const onlySent = coalesced === 0 && rateLimited === 0 && skipped === 0;
ui.notify(lines.join(' '), onlySent ? 'success' : 'info');
}
// Members grouped by role, highest privilege first.
const memberGroups = $derived(
ROLE_ORDER.map((role) => ({
role,
members: members.filter((m) => m.role === role)
})).filter((g) => g.members.length > 0)
);
// ── Public link ──────────────────────────────────────────────────────────
let shares = $state<ShareItem[]>([]);
let linkLoading = $state(false);
let creating = $state(false);
let newLinkName = $state('');
let password = $state('');
let expiresAt = $state<string | null>(null);
async function loadShares() {
if (!item) return;
linkLoading = true;
try {
shares = await listSharesForItem(item.id, item.kind);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
linkLoading = false;
}
}
async function createLink() {
if (!item) return;
creating = true;
try {
await createShare({
itemId: item.id,
itemName: newLinkName.trim() || item.name,
itemType: item.kind,
password: password || null,
expiresAt: expiresAt || null
});
newLinkName = '';
password = '';
expiresAt = null;
await loadShares();
ui.notify(t('share.created', 'Public link created'), 'success');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
} finally {
creating = false;
}
}
async function editLinkExpiry(share: ShareItem, expiry: string | null) {
try {
await updateShare(share.id, { expiresAt: expiry });
await loadShares();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function editLinkPassword(share: ShareItem, pw: string | null) {
try {
await updateShare(share.id, { password: pw });
await loadShares();
ui.notify(
pw
? t('share.password_set', 'Password updated')
: t('share.password_cleared', 'Password removed'),
'success'
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeLink(share: ShareItem) {
try {
await deleteShare(share.id);
shares = shares.filter((s) => s.id !== share.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function copy(url: string) {
if (await copyShareLink(url)) ui.notify(t('share.copied', 'Link copied'), 'success');
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
}
function shareExpiryIso(s: ShareItem): string | null {
return s.expires_at ? new Date(s.expires_at * 1000).toISOString().slice(0, 10) : null;
}
$effect(() => {
if (open && item) {
void loadGrants();
void loadShares();
}
});
</script>
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
<span class="chip-edit">
{#if value}
<input
class="chip-edit__date"
type="date"
value={value ?? ''}
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.expiry', 'Expiry')}
/>
<button
class="chip-edit__clear"
title={t('actions.clear', 'Clear')}
onclick={() => onchange(null)}
aria-label={t('actions.clear', 'Clear')}>×</button
>
{:else}
<label class="chip chip--ghost">
<Icon name="infinity" />
<span>{t('share.noExpiry', 'No expiry')}</span>
<input
class="chip-edit__date chip-edit__date--hidden"
type="date"
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
aria-label={t('share.set_expiry', 'Set expiry')}
/>
</label>
{/if}
</span>
{/snippet}
<Modal bind:open title={t('share.dialog_title', { name: item?.name ?? '' }, 'Share “{{name}}”')}>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'people'} onclick={() => (tab = 'people')}>
{t('share.people', 'People')}
</button>
<button role="tab" aria-selected={tab === 'link'} onclick={() => (tab = 'link')}>
{t('share.public_link', 'Public link')}
</button>
</div>
{#if tab === 'people'}
{#if !directoryAvailable && !grantsLoading}
<p class="status status--note">
{t('share.directoryUnavailable', 'User directory unavailable')}
</p>
{:else}
<div class="add-row">
<div class="search">
<input
placeholder={t('share.add_people', 'Add people, groups, or email…')}
bind:value={query}
oninput={onQueryInput}
autocomplete="off"
/>
{#if results.length > 0}
<ul class="results">
{#each results as r (r.type + r.id)}
<li>
<button class="result" onclick={() => addRecipient(r)}>
<Icon
name={r.type === 'group'
? 'user-group'
: r.type === 'email'
? 'envelope'
: 'user'}
/>
<span class="result__label">{r.label}</span>
{#if r.type === 'email'}
<span class="result__sub">{t('share.inviteByEmail', 'Invite by email')}</span>
{:else if r.sublabel}
<span class="result__sub">{r.sublabel}</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<select class="role-select" bind:value={newRole} aria-label={t('share.role', 'Role')}>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
{@render expiryChip(newExpiry, (v) => (newExpiry = v))}
</div>
{/if}
{#if grantsLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--short"></div>
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if members.length === 0}
<p class="status">{t('share.no_people', 'Not shared with anyone yet.')}</p>
{:else}
{#each memberGroups as group (group.role)}
<div class="member-group">
<div class="member-group__header">
<Icon name={roleIcon(group.role)} />
<span>{roleLabel(group.role)}</span>
<span class="member-group__badge">{group.members.length}</span>
</div>
<ul class="members">
{#each group.members as m (m.subject.type + m.subject.id)}
<li
class="member"
class:member--expired={m.expiry && new Date(m.expiry) < new Date()}
>
<Icon name={m.subject.type === 'group' ? 'user-group' : 'user'} />
<span class="member__label">
{m.recipient.label}
{#if m.recipient.sublabel}<span class="member__sub">{m.recipient.sublabel}</span
>{/if}
</span>
{@render expiryChip(m.expiry, (v) => changeMemberExpiry(m, v))}
<select
class="role-select"
value={m.role}
onchange={(e) => changeRole(m, e.currentTarget.value as ShareRole)}
>
{#each ROLES as r (r.v)}<option value={r.v}>{r.l}</option>{/each}
</select>
<button
class="btn-action"
title={t('share.notifyByEmail', 'Notify by email')}
onclick={() => notifyMember(m)}><Icon name="paper-plane" /></button
>
<button
class="btn-action btn-action--delete"
title={t('share.revoke', 'Remove')}
onclick={() => removeMember(m)}><Icon name="user-xmark" /></button
>
</li>
{/each}
</ul>
</div>
{/each}
{/if}
{:else}
<section class="sh-create">
<div class="sh-fields">
<label>
<span>{t('share.link_name', 'Link name (optional)')}</span>
<input type="text" bind:value={newLinkName} autocomplete="off" />
</label>
<label>
<span>{t('share.password_optional', 'Password (optional)')}</span>
<input type="text" bind:value={password} autocomplete="off" />
</label>
<label>
<span>{t('share.expires_optional', 'Expires (optional)')}</span>
<input
type="date"
value={expiresAt ?? ''}
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
/>
</label>
</div>
<button class="btn btn-primary" disabled={creating} onclick={createLink}>
{t('share.create_link', 'Create link')}
</button>
</section>
{#if linkLoading}
<div class="skeleton" aria-hidden="true">
<div class="skeleton__line skeleton__line--medium"></div>
<div class="skeleton__line"></div>
</div>
{:else if shares.length === 0}
<p class="status">{t('share.none', 'No public links yet.')}</p>
{:else}
<ul class="links">
{#each shares as s (s.id)}
<li class="link-row">
<span class="link-row__title">
<Icon name={s.has_password ? 'lock' : 'link'} />
<span class="link-row__name"
>{s.item_name || t('share.sharedLink', 'Shared link')}</span
>
</span>
{@render expiryChip(shareExpiryIso(s), (v) => editLinkExpiry(s, v))}
<button
class="btn-action"
class:btn-action--on={s.has_password}
title={s.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
onclick={() => {
const pw = window.prompt(
s.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw !== null) editLinkPassword(s, pw || null);
}}><Icon name={s.has_password ? 'lock' : 'lock-open'} /></button
>
<button class="btn-action" title={t('share.copy', 'Copy')} onclick={() => copy(s.url)}>
<Icon name="copy" />
</button>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={() => removeLink(s)}><Icon name="trash" /></button
>
</li>
{/each}
</ul>
{/if}
{/if}
{#snippet footer()}
<button class="btn btn-secondary" onclick={() => (open = false)}>
{t('common.close', 'Close')}
</button>
{/snippet}
</Modal>
<style>
.tabs {
display: flex;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.tabs button {
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.tabs button[aria-selected='true'] {
color: var(--color-text);
border-bottom-color: var(--color-accent);
}
.add-row {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-3);
align-items: center;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 12rem;
}
.search input,
.role-select,
.sh-fields input {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.search input {
width: 100%;
}
.results {
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: 10;
list-style: none;
margin: var(--space-1) 0 0;
padding: var(--space-1);
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 14rem;
overflow: auto;
}
.result {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
}
.result:hover {
background: var(--color-bg-hover);
}
.result__label {
flex: 1;
}
.result__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.member-group {
margin-bottom: var(--space-3);
}
.member-group__header {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
font-weight: var(--weight-semibold, 600);
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.member-group__badge {
min-width: 1.25rem;
text-align: center;
padding: 0 var(--space-1);
border-radius: var(--radius-pill, 999px);
background: var(--color-bg-muted);
color: var(--color-text-muted);
font-size: var(--text-xs, 0.75rem);
}
.members,
.links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.member {
display: flex;
align-items: center;
gap: var(--space-2);
}
.member--expired {
opacity: 0.6;
}
.member__label {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member__sub {
color: var(--color-text-muted);
font-size: var(--text-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sh-fields {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.sh-fields label {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 8rem;
font-size: var(--text-sm);
}
.link-row {
display: flex;
align-items: center;
gap: var(--space-2);
}
.link-row__title {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
overflow: hidden;
}
.link-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status {
color: var(--color-text-muted);
padding: var(--space-3) 0;
}
.status--note {
font-style: italic;
}
.btn-action--delete:hover {
color: var(--color-danger-text);
}
.btn-action--on {
color: var(--color-accent);
}
/* ── Expiry chip ─────────────────────────────────────────────────────── */
.chip-edit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
border: 1px solid var(--color-border);
font-size: var(--text-sm);
color: var(--color-text);
cursor: pointer;
position: relative;
}
.chip--ghost {
border-style: dashed;
color: var(--color-text-muted);
}
.chip-edit__date {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: var(--text-sm);
}
.chip-edit__date--hidden {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.chip-edit__clear {
border: none;
background: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: var(--text-md, 1rem);
line-height: 1;
}
/* ── Loading skeleton ────────────────────────────────────────────────── */
.skeleton {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) 0;
}
.skeleton__line {
height: 1rem;
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
var(--color-bg-muted) 25%,
var(--color-bg-hover) 37%,
var(--color-bg-muted) 63%
);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
.skeleton__line--short {
width: 40%;
}
.skeleton__line--medium {
width: 65%;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
</style>
+7 -2
View File
@@ -16,13 +16,17 @@
<style>
.toaster {
position: fixed;
bottom: 1rem;
/* Offset clears any bottom-right FAB the file view may mount; the
--toaster-offset hook lets a page lift the stack further if needed. */
bottom: calc(1rem + env(safe-area-inset-bottom, 0px) + var(--toaster-offset, 0px));
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
z-index: 1200;
max-width: min(92vw, 24rem);
/* Let clicks pass through the gaps; individual toasts re-enable below. */
pointer-events: none;
}
.toast {
@@ -35,6 +39,7 @@
color: var(--color-text);
box-shadow: var(--shadow-md);
border-left: 4px solid var(--color-border);
pointer-events: auto;
}
.toast--success {
@@ -0,0 +1,173 @@
<script lang="ts">
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
interface Props {
open: boolean;
fileId: string | null;
fileName: string;
action?: 'edit' | 'view';
onclose?: () => void;
}
let { open = $bindable(false), fileId, fileName, action = 'edit', onclose }: Props = $props();
let form = $state<HTMLFormElement | null>(null);
let editorUrl = $state('');
let token = $state('');
let tokenTtl = $state('');
let loading = $state(false);
function close() {
open = false;
editorUrl = '';
onclose?.();
}
function onKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') close();
}
// The editor iframe posts status messages (Collabora / OnlyOffice WOPI
// protocol). We drop the spinner once it loads, and close the host modal
// when the editor's own close button fires UI_Close / Document close.
function onMessage(e: MessageEvent) {
if (!open) return;
let data: Record<string, unknown>;
try {
data = JSON.parse(typeof e.data === 'string' ? e.data : '') as Record<string, unknown>;
} catch {
return; // not a JSON message — ignore
}
const msgId = String(data.MessageId ?? data.messageId ?? '');
if (msgId === 'UI_Close' || msgId === 'close') {
close();
} else if (msgId === 'App_LoadingStatus') {
const values = data.Values as { Status?: string } | undefined;
const status = values?.Status;
if (status === 'Document_Loaded' || status === 'Frame_Ready') {
loading = false;
}
}
}
// When opened, fetch the editor URL + token, then submit the (hidden) form
// into the iframe — this is the WOPI host-page POST handshake.
$effect(() => {
if (!open || !fileId) return;
loading = true;
editorUrl = '';
getEditorUrlWithFallback(fileId, fileName, action)
.then((data) => {
editorUrl = data.editor_url;
token = data.access_token;
tokenTtl = String(data.access_token_ttl);
// Submit on the next microtask once the form has the values bound.
queueMicrotask(() => form?.submit());
})
.catch((e) => {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
close();
})
.finally(() => (loading = false));
});
</script>
<svelte:window onkeydown={onKeydown} onmessage={onMessage} />
{#if open}
<div class="wopi" role="dialog" aria-modal="true" aria-label={fileName}>
<header class="wopi__bar">
<span class="wopi__title">{fileName}</span>
<button class="wopi__close" aria-label={t('common.close', 'Close')} onclick={close}>
<Icon name="times" />
</button>
</header>
<div class="wopi__frame-wrap">
{#if loading}
<p class="wopi__status">{t('common.loading', 'Loading…')}</p>
{/if}
{#if editorUrl}
<form
bind:this={form}
action={editorUrl}
method="post"
target="wopi_frame"
class="wopi__form"
>
<input type="hidden" name="access_token" value={token} />
<input type="hidden" name="access_token_ttl" value={tokenTtl} />
</form>
{/if}
<iframe
name="wopi_frame"
title={t('files.editor', 'Document editor')}
class="wopi__frame"
allow="clipboard-read; clipboard-write"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation allow-popups-to-escape-sandbox"
></iframe>
</div>
</div>
{/if}
<style>
.wopi {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
background: var(--color-bg-base, var(--color-bg-surface));
}
.wopi__bar {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 1rem;
background: var(--color-bg-elevated, var(--color-bg-surface));
border-bottom: 1px solid var(--color-border);
color: var(--color-text-heading);
}
.wopi__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wopi__close {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1.1rem;
}
.wopi__frame-wrap {
position: relative;
flex: 1;
}
.wopi__form {
display: none;
}
.wopi__frame {
width: 100%;
height: 100%;
border: none;
}
.wopi__status {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
}
</style>
+49 -1
View File
@@ -14,7 +14,7 @@ import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
// Keep in sync with the locale files in static/locales (and, post-cutover,
// frontend/static/locales). Mirrors AVAILABLE_LOCALES in the legacy selector.
// frontend/static/locales). Mirrors AVAILABLE_LOCALES in the language selector.
export const SUPPORTED_LOCALES = [
'en',
'es',
@@ -36,8 +36,54 @@ export const SUPPORTED_LOCALES = [
export type Locale = (typeof SUPPORTED_LOCALES)[number];
/** Locales that render right-to-left. */
const RTL_LOCALES: readonly Locale[] = ['fa', 'ar'];
export interface LanguageMeta {
code: Locale;
/** Endonym (native language name). */
name: string;
/** Flag emoji. */
flag: string;
}
/**
* Display metadata for the language selector — native names + flags, ported
* from ALL_LANGUAGES in static/js/features/auth/auth.js. Order matches
* SUPPORTED_LOCALES so the rich dropdown lists the same set as `t()` resolves.
*/
export const LANGUAGES: readonly LanguageMeta[] = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'zh', name: '简体中文', flag: '🇨🇳' },
{ code: 'zh-TW', name: '繁體中文', flag: '🇹🇼' },
{ code: 'fa', name: 'فارسی', flag: '🇮🇷' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
{ code: 'nl', name: 'Nederlands', flag: '🇳🇱' },
{ code: 'it', name: 'Italiano', flag: '🇮🇹' },
{ code: 'hi', name: 'हिन्दी', flag: '🇮🇳' },
{ code: 'ar', name: 'العربية', flag: '🇸🇦' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
{ code: 'ko', name: '한국어', flag: '🇰🇷' },
{ code: 'pl', name: 'Polski', flag: '🇵🇱' }
];
const STORAGE_KEY = 'oxicloud-locale';
/**
* Reflect the active locale on `<html>`: sets `lang` and flips `dir` to `rtl`
* for Farsi/Arabic (and `ltr` otherwise) so the ported [dir="rtl"] CSS engages.
*/
function applyHtmlLang(locale: string): void {
if (typeof document === 'undefined') return;
const html = document.documentElement;
html.setAttribute('lang', locale);
html.setAttribute('dir', (RTL_LOCALES as readonly string[]).includes(locale) ? 'rtl' : 'ltr');
}
type Dict = Record<string, unknown>;
/**
@@ -162,6 +208,7 @@ export async function initI18n(): Promise<void> {
}
await loadDict(store.locale);
if (store.locale !== 'en') await loadDict('en');
applyHtmlLang(store.locale);
store.loaded = true;
}
@@ -172,6 +219,7 @@ export async function setLocale(locale: Locale): Promise<boolean> {
}
await loadDict(locale);
store.locale = locale;
applyHtmlLang(locale);
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, locale);
persistLocaleToServer(locale);
return true;
+113
View File
@@ -0,0 +1,113 @@
/**
* Promise-based confirm/prompt dialogs, rendered by <DialogHost> in the root
* layout. Replaces the browser's native `confirm()`/`prompt()` with in-app
* modals that match the rest of the UI. One dialog at a time (queued).
*
* Dialogs can carry an async `action`: when present the host runs it on submit
* and only closes the dialog if it resolves. A rejection keeps the dialog open
* and surfaces an inline error, so failed renames/deletes don't silently vanish.
*/
export interface ConfirmOptions {
title: string;
message?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
/** Optional async action run on confirm; rejection keeps the dialog open. */
action?: () => Promise<void> | void;
}
export interface PromptOptions {
title: string;
message?: string;
defaultValue?: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
/**
* Pre-select the input text on open. `'name'` selects the filename portion
* (excluding the extension) — used by rename so typing replaces just the
* stem. `true` selects everything; omit/`false` to leave the caret at end.
*/
selectOnOpen?: boolean | 'name';
/** Optional async action run with the entered value; rejection keeps it open. */
action?: (value: string) => Promise<void> | void;
}
type Pending =
| { kind: 'confirm'; opts: ConfirmOptions; resolve: (v: boolean) => void }
| { kind: 'prompt'; opts: PromptOptions; resolve: (v: string | null) => void };
class DialogStore {
current = $state<Pending | null>(null);
/** Inline error message for the current dialog (from a failed action). */
error = $state<string | null>(null);
/** True while the current dialog's async action is running. */
busy = $state(false);
#queue: Pending[] = [];
#enqueue(p: Pending) {
if (this.current) this.#queue.push(p);
else {
this.current = p;
this.error = null;
this.busy = false;
}
}
#next() {
this.error = null;
this.busy = false;
this.current = this.#queue.shift() ?? null;
}
confirm(opts: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => this.#enqueue({ kind: 'confirm', opts, resolve }));
}
prompt(opts: PromptOptions): Promise<string | null> {
return new Promise((resolve) => this.#enqueue({ kind: 'prompt', opts, resolve }));
}
/**
* Called by the host when the user confirms (with a value for prompts).
* When the dialog carries an `action`, runs it first: on success the dialog
* closes and the promise resolves; on failure the dialog stays open with an
* inline error and the promise does NOT resolve yet.
*/
async resolve(value: boolean | string | null) {
const c = this.current;
if (!c) return;
const action = c.kind === 'confirm' ? c.opts.action : (c.opts as PromptOptions).action;
if (action) {
this.busy = true;
this.error = null;
try {
if (c.kind === 'confirm') await (action as () => Promise<void> | void)();
else await (action as (v: string) => Promise<void> | void)(value as string);
} catch (err) {
this.busy = false;
this.error = err instanceof Error ? err.message : String(err);
return; // keep the dialog open
}
}
if (c.kind === 'confirm') c.resolve(value as boolean);
else c.resolve(value as string | null);
this.#next();
}
/** Cancel/dismiss the current dialog. */
cancel() {
const c = this.current;
if (!c || this.busy) return;
if (c.kind === 'confirm') c.resolve(false);
else c.resolve(null);
this.#next();
}
}
export const dialogs = new DialogStore();
/** Convenience wrappers. */
export const confirmDialog = (opts: ConfirmOptions) => dialogs.confirm(opts);
export const promptDialog = (opts: PromptOptions) => dialogs.prompt(opts);
+73 -1
View File
@@ -1,12 +1,84 @@
/**
* Files view state — replaces the navigation-related fields of the legacy `app`
* Files view state — replaces the navigation-related fields of the original `app`
* state object (currentFolder, currentFolderInfo, breadcrumbPath, view mode,
* section, selection). Dialog/context-menu targets stay component-local until a
* view proves they must be shared.
*/
import type { FolderItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
export type ViewMode = 'grid' | 'list';
// ── Group-by / display helpers ───────────────────────────────────────────────
// Ported from static/js/core/formatters.js (sizeBucket, normalizeDateBucket,
// formatRelativeTime) and static/js/components/resourceList.js (type label,
// owner label). Pure functions, shared by the files view's swimlane grouping
// and cell rendering so the same bucketing logic isn't duplicated per call site.
/** Normalise an epoch (seconds or ms) into a Date. */
function toDate(value: number): Date {
return new Date(value < 1e12 ? value * 1000 : value);
}
/** Coarse size bucket label. `bytes < 0` is the "Folders" sentinel. */
export function sizeBucket(bytes: number): string {
if (bytes < 0) return t('sizeBucket.folders', 'Folders');
if (bytes === 0) return t('sizeBucket.empty', 'Empty (0 B)');
if (bytes < 1_048_576) return t('sizeBucket.tiny', '< 1 MB');
if (bytes < 104_857_600) return t('sizeBucket.small', '1 – 100 MB');
if (bytes < 1_073_741_824) return t('sizeBucket.medium', '100 MB – 1 GB');
if (bytes < 5 * 1_073_741_824) return t('sizeBucket.large', '1 – 5 GB');
return t('sizeBucket.huge', '> 5 GB');
}
/** Coarse date bucket: Today | Last 7 days | Last 30 days | <YYYY>. */
export function dateBucket(value: number | null | undefined): string {
if (!value) return t('dateBucket.unknown', 'Unknown');
const diffDays = Math.floor((Date.now() - toDate(value).getTime()) / 86_400_000);
if (diffDays <= 0) return t('dateBucket.today', 'Today');
if (diffDays <= 7) return t('dateBucket.last7days', 'Last 7 days');
if (diffDays <= 30) return t('dateBucket.last30days', 'Last 30 days');
return String(toDate(value).getFullYear());
}
/** Locale-aware relative "time ago" for grid-card metadata lines. */
export function relativeTimeAgo(value: number | null | undefined): string {
if (!value) return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['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);
}
return rtf.format(diffSec, 'second');
}
/** Localise a file `category` (e.g. "Image") via files.file_types.* keys. */
export function typeLabel(category: string | null | undefined): string {
if (!category) return t('files.file_types.document', 'Document');
return t(`files.file_types.${category.toLowerCase()}`, category);
}
/** Owner display: "Me" for the current user, else a short id fallback. */
export function ownerLabel(
ownerId: string | null | undefined,
currentUserId: string | null
): string {
if (!ownerId) return '';
if (currentUserId && ownerId === currentUserId) return t('files.owner_me', 'Me');
return ownerId.slice(0, 8);
}
export type Section =
| 'files'
| 'shared'
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Session store — the authenticated user and derived flags.
*
* Replaces the user-related fields of the legacy `app` state object
* Replaces the user-related fields of the original `app` state object
* (isExternalUser, userHomeFolderId/Name). `isExternalUser` drives default
* routing: externals (magic-link / OIDC-only / OCM recipients) have no home
* folder and land on the shared-with-me view.
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Theme store — light / dark / auto.
*
* Mirrors the legacy behaviour: persists to the `oxicloud_theme` localStorage
* Mirrors the established behaviour: persists to the `oxicloud_theme` localStorage
* key and reflects the choice on `<html data-color-scheme>`. `auto` removes the
* attribute so the OS `prefers-color-scheme` takes over. The anti-FOUC inline
* script in app.html applies the stored value before first paint; this store
+148 -4
View File
@@ -1,7 +1,8 @@
/**
* Transient UI state — toasts now; cross-component dialog targets are added as
* the views that need them land (Phases 2–4). Component-local state is preferred;
* only state that must cross component boundaries belongs here.
* Transient UI state — toasts plus the persistent notification feed shown in the
* top-bar bell. `notify()` raises a transient toast and records a notification
* entry (so uploads, errors and successes accumulate in the bell). Component-local
* state is preferred; only state that must cross component boundaries lives here.
*/
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
@@ -11,13 +12,79 @@ export interface Toast {
kind: ToastKind;
}
export interface Notification {
id: number;
message: string;
kind: ToastKind;
at: number;
read: boolean;
/** 0–100 while an operation is in progress; undefined for plain notifications. */
progress?: number;
/** Optional icon-registry name override (defaults derived from kind). */
icon?: string;
/** Current per-file label (e.g. the filename being uploaded). */
currentFile?: string;
/** Files finished so far in the batch (for the "N / M files" counter). */
completed?: number;
/** Total files in the batch (for the "N / M files" counter). */
total?: number;
}
/** Announce a message to the matching ARIA live region (errors are assertive). */
function announce(message: string, assertive = false): void {
const msg = message.trim();
if (!msg || typeof document === 'undefined' || !document.body) return;
const id = assertive ? 'a11y-live-assertive' : 'a11y-live-polite';
let region = document.getElementById(id);
if (!region) {
region = document.createElement('div');
region.id = id;
region.className = 'sr-only';
region.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
region.setAttribute('aria-atomic', 'true');
region.setAttribute('role', assertive ? 'alert' : 'status');
document.body.appendChild(region);
}
// Clear first, then set next frame so repeats register as a change.
region.textContent = '';
const target = region;
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => (target.textContent = msg));
} else {
target.textContent = msg;
}
}
class UiStore {
toasts = $state<Toast[]>([]);
notifications = $state<Notification[]>([]);
#seq = 0;
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000): number {
/**
* Bumped to request the bell panel auto-open (e.g. on upload start) and to
* trigger the bell "ring" animation. AppShell watches this token.
*/
bellPing = $state(0);
unread = $derived(this.notifications.filter((n) => !n.read).length);
/** Unread count clamped for the badge — caps at "99+" like the original. */
unreadBadge = $derived(this.unread > 99 ? '99+' : String(this.unread));
/**
* Raise a toast and record a notification. `at` is stamped from the clock at
* call time; pass `record: false` for purely transient messages.
*/
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000, record = true): number {
const id = ++this.#seq;
this.toasts = [...this.toasts, { id, message, kind }];
if (record) {
this.notifications = [
{ id, message, kind, at: Date.now(), read: false },
...this.notifications
];
}
announce(message, kind === 'error');
if (timeoutMs > 0 && typeof setTimeout !== 'undefined') {
setTimeout(() => this.dismiss(id), timeoutMs);
}
@@ -27,6 +94,83 @@ class UiStore {
dismiss(id: number): void {
this.toasts = this.toasts.filter((t) => t.id !== id);
}
/** Request the bell panel to open and play its ring animation. */
ringBell(): void {
this.bellPing++;
}
/**
* Begin a progress notification (e.g. an upload). Pass `total` to show the
* "N / M files" counter. Opens the bell, rings it, and announces the start.
*/
startProgress(message: string, icon = 'cloud-upload-alt', total?: number): number {
const id = ++this.#seq;
this.notifications = [
{
id,
message,
kind: 'info',
at: Date.now(),
read: false,
progress: 0,
icon,
...(total !== undefined ? { total, completed: 0 } : {})
},
...this.notifications
];
this.ringBell();
announce(message);
return id;
}
/** Update the percentage (0–100) of an in-flight progress notification. */
updateProgress(
id: number,
progress: number,
message?: string,
extra?: { currentFile?: string; completed?: number }
): void {
this.notifications = this.notifications.map((n) =>
n.id === id
? {
...n,
progress,
...(message ? { message } : {}),
...(extra?.currentFile !== undefined ? { currentFile: extra.currentFile } : {}),
...(extra?.completed !== undefined ? { completed: extra.completed } : {})
}
: n
);
}
/** Resolve a progress notification into a final success/error entry. */
finishProgress(id: number, message: string, kind: ToastKind = 'success'): void {
this.notifications = this.notifications.map((n) =>
n.id === id
? {
...n,
message,
kind,
progress: undefined,
currentFile: undefined,
at: Date.now()
}
: n
);
this.toasts = [...this.toasts, { id: ++this.#seq, message, kind }];
announce(message, kind === 'error');
const tid = this.#seq;
if (typeof setTimeout !== 'undefined') setTimeout(() => this.dismiss(tid), 4000);
}
markNotificationsRead(): void {
this.notifications = this.notifications.map((n) => (n.read ? n : { ...n, read: true }));
}
clearNotifications(): void {
this.notifications = [];
}
}
export const ui = new UiStore();
+1 -1
View File
@@ -7,4 +7,4 @@
@import url('./base/forms.css');
@import url('./base/animations.css');
@import url('./base/a11y.css');
@import url('./legacy.css');
@import url('./ported.css');
-13
View File
@@ -1,13 +0,0 @@
/* Vendored layout/component CSS ported verbatim from the original static/css.
* These are token-based and global; the Svelte components emit the same class
* names and DOM so the new app matches the original look. Kept byte-faithful
* (linters ignore this dir) — restyle via tokens in variables.css, not here. */
@import url('./legacy/sidebar.css');
@import url('./legacy/topbar.css');
@import url('./legacy/content.css');
@import url('./legacy/buttons.css');
@import url('./legacy/breadcrumb.css');
@import url('./legacy/fileManager.css');
@import url('./legacy/resourceList.css');
@import url('./legacy/skeleton.css');
@import url('./legacy/auth.css');
+18
View File
@@ -0,0 +1,18 @@
/* Layout/component CSS ported verbatim from the OxiCloud frontend's static/css.
* These are token-based and global; the Svelte components emit the same class
* names and DOM so the rewrite matches the established design. Kept byte-faithful
* (linters ignore this dir) — restyle via tokens in variables.css, not here. */
@import url('./ported/sidebar.css');
@import url('./ported/topbar.css');
@import url('./ported/content.css');
@import url('./ported/buttons.css');
@import url('./ported/uploadDropdown.css');
@import url('./ported/breadcrumb.css');
@import url('./ported/fileManager.css');
@import url('./ported/resourceList.css');
@import url('./ported/batchToolbar.css');
@import url('./ported/skeleton.css');
@import url('./ported/notifications.css');
@import url('./ported/userMenu.css');
@import url('./ported/auth.css');
@import url('./ported/music.css');
@@ -0,0 +1,101 @@
/* Multi-Select – batch action toolbar
* Per-item checkbox styles (.file-item .checkbox-cell, .list-header.selection-mode)
* live in resourceList.css alongside the item renderer. */
.batch-selection-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
min-width: 0;
}
.batch-selection-bar {
display: flex;
align-items: center;
background: var(--color-multiselect-bg);
color: var(--color-multiselect-text);
padding: var(--space-2-5) var(--space-5);
border-radius: var(--radius-2xl);
overflow: hidden;
margin-right: var(--space-3);
height: 60px;
transform: translateY(-8px);
transition:
opacity 0.2s,
max-height 0.25s,
transform 0.2s,
margin 0.2s,
padding 0.2s;
pointer-events: auto;
}
.batch-bar-close {
background: none;
border: none;
color: var(--color-multiselect-text-faint);
cursor: pointer;
font-size: var(--text-base);
padding: var(--space-1) var(--space-1-5);
border-radius: var(--radius-md);
transition:
background 0.15s,
color 0.15s;
}
.batch-bar-close:hover {
background: var(--color-multiselect-hover-bg);
color: var(--color-multiselect-text);
}
.batch-bar-count {
font-size: var(--text-base);
font-weight: var(--weight-semibold);
white-space: nowrap;
}
.batch-bar-actions {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.batch-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
padding: 7px var(--space-3-5);
border: none;
border-radius: var(--radius-lg);
background: var(--color-multiselect-hover-bg);
color: var(--color-multiselect-action-text);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.batch-btn:hover {
background: var(--color-multiselect-action-hover);
}
.batch-btn-danger {
background: var(--color-multiselect-danger-bg);
color: var(--color-multiselect-danger-text);
}
.batch-btn-danger:hover {
background: var(--color-multiselect-danger-active);
color: var(--color-multiselect-danger-text-active);
}
@media (max-width: 640px) {
.batch-btn span {
display: none;
}
.batch-btn {
padding: 7px var(--space-2-5);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,371 @@
/* Notification */
.notification {
position: absolute;
top: 70px;
right: 20px;
background-color: var(--color-notification-bg);
width: 250px;
border-radius: var(--radius-lg);
box-shadow: 0 5px 15px var(--color-shadow);
padding: 15px;
border-left: 4px solid var(--color-accent);
z-index: 1000;
display: none;
[dir="rtl"] & {
left: 20px;
border-right: 4px solid var(--color-accent);
right: unset;
border-left: unset;
}
}
.notification-title {
font-weight: var(--weight-bold);
font-size: var(--text-base);
margin-bottom: 5px;
color: var(--color-text);
}
.notification-message {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
/* Notification banner */
.notification-banner {
position: fixed;
top: 20px;
right: 20px;
padding: 15px var(--space-5);
background-color: var(--color-notification-bg);
border-radius: var(--radius-lg);
box-shadow: 0 4px 12px var(--color-shadow);
display: flex;
align-items: center;
justify-content: space-between;
max-width: 400px;
z-index: 2000;
transform: translateY(-100px);
opacity: 0;
transition:
transform 0.3s,
opacity 0.3s;
}
.notification-banner.active {
transform: translateY(0);
opacity: 1;
}
.notification-banner.success {
border-left: 4px solid var(--color-success-border);
}
.notification-banner.error {
border-left: 4px solid var(--color-danger-bg);
}
.close-notification-btn {
background: none;
border: none;
font-size: var(--text-lg);
cursor: pointer;
color: var(--color-text-placeholder);
margin-left: var(--space-2-5);
}
/* Notification Bell */
.notif-wrapper {
position: relative;
}
.notif-bell-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--text-lg);
color: var(--color-text-subtle);
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
position: relative;
}
.notif-bell-btn:hover {
background: var(--color-accent-bg-sm);
color: var(--color-accent);
}
.notif-bell-btn.active {
color: var(--color-accent);
background: var(--color-accent-ring);
}
.notif-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
line-height: 16px;
border-radius: var(--radius-lg);
background: var(--color-notification-badge);
color: var(--color-notification-bg);
font-size: 10px;
font-weight: var(--weight-bold);
text-align: center;
padding: 0 var(--space-1);
pointer-events: none;
}
@keyframes bellRing {
0%,
100% {
transform: rotate(0deg);
}
13% {
transform: rotate(22deg);
}
26% {
transform: rotate(-22deg);
}
39% {
transform: rotate(14deg);
}
52% {
transform: rotate(-14deg);
}
65% {
transform: rotate(8deg);
}
78% {
transform: rotate(-8deg);
}
91% {
transform: rotate(3deg);
}
}
.notif-bell-btn.ring {
animation: bellRing 1s ease;
}
.notif-panel {
display: none;
position: absolute;
top: calc(100% + 10px);
right: -40px;
width: 380px;
max-height: 480px;
background: var(--color-notification-bg);
border-radius: var(--radius-3xl);
box-shadow:
0 12px 40px var(--color-shadow-md),
0 0 0 1px var(--color-shadow-xs);
z-index: 2000;
overflow: hidden;
animation: notifPanelIn 0.2s ease-out;
}
.notif-wrapper.open .notif-panel {
display: flex;
flex-direction: column;
}
@keyframes notifPanelIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.notif-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3-5) var(--space-4);
border-bottom: 1px solid var(--color-border-xfaint);
}
.notif-panel-title {
font-weight: var(--weight-semibold);
font-size: 15px;
color: var(--color-text-heading);
}
.notif-clear-btn {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-faint);
font-size: var(--text-base);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
transition: all 0.15s;
}
.notif-clear-btn:hover {
color: var(--color-accent);
background: var(--color-accent-bg-sm);
}
.notif-panel-body {
flex: 1;
overflow-y: auto;
max-height: 400px;
/* Thin, neutral scrollbar — replaces the heavy global accent bar that read
as amateur in the panel. */
scrollbar-width: thin;
scrollbar-color: var(--color-border-medium) transparent;
}
.notif-panel-body::-webkit-scrollbar {
width: 6px;
}
.notif-panel-body::-webkit-scrollbar-thumb {
background: var(--color-border-medium);
border-radius: var(--radius-full);
}
.notif-panel-body::-webkit-scrollbar-track {
background: transparent;
}
.notif-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-10) var(--space-5);
color: var(--color-text-faint);
gap: var(--space-2);
}
.notif-empty i {
font-size: var(--text-3xl);
opacity: 0.5;
}
.notif-empty span {
font-size: var(--text-base);
}
.notif-item {
display: flex;
align-items: flex-start;
padding: var(--space-3) var(--space-4);
gap: var(--space-3);
border-bottom: 1px solid var(--color-bg-subtle);
transition: background 0.15s;
cursor: default;
}
.notif-item:last-child {
border-bottom: none;
}
.notif-item:hover {
background: var(--color-bg-hover);
}
.notif-item-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: var(--text-base);
}
.notif-item-icon.upload {
background: var(--color-accent-ring);
color: var(--color-accent);
}
.notif-item-icon.success {
background: var(--color-success-ring);
color: var(--color-notification-success);
}
.notif-item-icon.error {
background: var(--color-notification-error-ring);
color: var(--color-notification-error);
}
.notif-item-body {
flex: 1;
min-width: 0;
}
.notif-item-title {
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
margin-bottom: var(--space-0-5);
}
.notif-item-text {
font-size: var(--text-xs);
color: var(--color-text-subtle);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notif-item-time {
font-size: var(--text-2xs);
color: var(--color-text-faint);
margin-top: 3px;
}
.notif-upload-progress {
margin-top: var(--space-1-5);
}
.notif-upload-bar {
height: 3px;
background: var(--color-bg-empty);
border-radius: var(--radius-xs);
overflow: hidden;
}
.notif-upload-fill {
height: 100%;
background: var(--color-accent);
width: 0%;
transition: width 0.2s ease;
border-radius: var(--radius-xs);
}
.notif-upload-fill.done {
background: var(--color-notification-success);
}
.notif-upload-fill.error {
background: var(--color-notification-error);
}
.notif-upload-detail {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 3px;
}
.notif-upload-pct,
.notif-upload-stats {
font-size: var(--text-2xs);
color: var(--color-text-faint);
}
@@ -392,6 +392,12 @@
text-align: right;
}
/* List rows expose the individual action buttons inline (on hover), so the
corner kebab is redundant here — it's the grid view's affordance. */
.files-list-view .file-item .action-cell .file-actions {
display: none;
}
/* Styles for the built-in action-cell buttons (favorite-star, kebab).
* `.btn-action` is excluded — it owns its own colors via the generic
* `.btn-action` rule + variant modifiers (e.g. `.btn-action--delete`). */
@@ -639,6 +645,20 @@
}
/* More actions button (three dots) — top-right of the thumbnail on a scrim. */
/* Grid cards surface actions through the corner kebab (.file-actions) + the
favorite star, both absolutely positioned below. The inline per-row action
buttons (share/move/rename/delete) belong to the list view only — hide them
here so they don't stack up along the bottom edge of the card. */
.files-grid-view .file-item .action-cell .btn-action {
display: none;
}
/* The favorite state is already shown by the corner star button, so the inline
name-cell favorite badge is redundant on grid cards. */
.files-grid-view .file-item .name-cell .item-badge {
display: none;
}
.files-grid-view .file-item .file-actions {
position: absolute;
top: calc(var(--space-3) + 8px);
@@ -0,0 +1,78 @@
/* Upload Dropdown — ported from static/css/components/uploadDropdown.css.
* Font Awesome `<i>` icons are emitted as `<svg class="oxi-icon">` by Icon.svelte,
* so the icon selectors target `.oxi-icon` instead of `i`. */
.upload-dropdown {
position: relative;
display: inline-block;
}
.upload-dropdown .btn-primary {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.upload-dropdown-menu {
display: block;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 200px;
background: var(--color-bg-surface);
border-radius: var(--radius-2xl);
box-shadow: 0 8px 30px var(--color-shadow-md);
border: 1px solid var(--color-border);
z-index: 1000;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.upload-dropdown-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
border: none;
background: none;
color: var(--color-text-dark);
font-size: var(--text-base);
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
}
.upload-dropdown-item:hover {
background: var(--color-border-light);
}
.upload-dropdown-item:active {
background: var(--color-border);
}
.upload-dropdown-item .oxi-icon {
width: 20px;
text-align: center;
color: var(--color-text-subtle);
font-size: 15px;
}
.upload-dropdown-item:first-child {
border-bottom: 1px solid var(--color-border-light);
}
.upload-caret {
margin-left: var(--space-1);
font-size: var(--text-xs);
}
+294
View File
@@ -0,0 +1,294 @@
/* User Menu */
.user-menu-wrapper {
position: relative;
}
.user-avatar-btn {
background: none;
border: none;
border-radius: 50%;
padding: 0;
cursor: pointer;
transition: transform var(--motion-base) var(--ease-standard);
display: flex;
align-items: center;
justify-content: center;
}
/* Premium hover: ONE soft accent ring hugging the avatar + a gentle warm glow
+ a subtle pop — replaces the old heavy double halo (button border ring with
a gap + a second avatar ring). */
.user-avatar-btn:hover {
transform: scale(1.05);
}
.user-avatar-btn:hover .user-vignette__avatar {
box-shadow:
0 0 0 3px var(--color-accent-ring),
0 3px 12px -2px var(--color-accent-shadow);
}
/* Menu open: a slightly firmer ring (same clean single-ring language). */
.user-menu-wrapper.open .user-avatar-btn .user-vignette__avatar {
box-shadow: 0 0 0 3px var(--color-accent-ring-strong);
}
/* ── Avatar vignette overrides ────────────────────────────────────────────── */
/* The toolbar button and dropdown header mount avatar-only userVignette
components. Sizing is owned by userVignette.css (--menu / --xl variants);
the rules below add the decoration that is specific to this context. */
.user-avatar-btn .user-vignette__avatar {
letter-spacing: 0.5px;
user-select: none;
/* Animate the ring/glow smoothly in AND out (transition on the base, not
:hover). */
transition: box-shadow var(--motion-base) var(--ease-standard);
}
.user-menu-header .user-vignette__avatar {
letter-spacing: 0.5px;
box-shadow: 0 4px 12px var(--color-accent-shadow);
}
.user-menu {
display: none;
position: absolute;
top: calc(100% + 10px);
right: 0;
left: auto;
width: 300px;
background: var(--color-bg-surface);
border-radius: var(--radius-3xl);
box-shadow:
0 12px 40px var(--color-shadow-md),
0 0 0 1px var(--color-shadow-xs);
z-index: 2000;
overflow: hidden;
animation: userMenuIn 0.2s ease-out;
}
.user-menu-wrapper.open .user-menu {
display: block;
}
[dir="rtl"] .user-menu {
right: auto;
left: 0;
}
@keyframes userMenuIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.user-menu-header {
display: flex;
align-items: center;
gap: var(--space-3-5);
padding: var(--space-5) var(--space-5) var(--space-4);
background: var(--color-user-menu-header-bg);
border-bottom: 1px solid var(--color-user-menu-header-border);
}
/* The header vignette (xl, name + email) fills the available width. */
.user-menu-header .user-vignette {
flex: 1;
min-width: 0;
}
/* Increase name prominence relative to the base vignette style. */
.user-menu-header .user-vignette__name {
font-size: 15px;
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
}
.user-menu-header .user-vignette__email {
font-size: 12.5px;
margin-top: 1px;
}
.user-menu-storage {
padding: var(--space-3-5) var(--space-5);
}
.user-menu-storage-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-2);
}
.user-menu-storage-label i {
font-size: var(--text-2xs);
color: var(--color-text-faint);
}
.user-menu-storage-bar {
height: 6px;
background: var(--color-border-light);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-1-5);
}
.user-menu-storage-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-second));
border-radius: 3px;
width: 0%;
transition: width 0.5s ease;
}
.user-menu-storage-text {
font-size: 11.5px;
color: var(--color-text-faint);
}
.user-menu-divider {
height: 1px;
background: var(--color-border-light);
margin: var(--space-1) 0;
}
.user-menu-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-5);
border: none;
background: none;
color: var(--color-text-dark);
font-size: var(--text-base);
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
}
.user-menu-item:hover,
.user-menu-item:focus-visible {
background: var(--color-bg-hover);
}
.user-menu-item i {
width: 20px;
text-align: center;
font-size: 15px;
color: var(--color-text-subtle);
}
/* The appearance row is a plain container, not a button — the inner
* segmented control captures the clicks. Match the height of other
* .user-menu-item rows so the row reads as part of the same list. */
.user-menu-item--theme {
cursor: default;
}
.user-menu-item--theme:hover {
background: transparent;
}
/* Light / Like OS / Dark — three-option pill, active option highlighted
* with the accent colour. Sits at the right edge of the row. */
.theme-segmented {
margin-left: auto;
display: inline-flex;
background: var(--color-bg-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-0-5);
gap: var(--space-0-5);
}
.theme-segmented__opt {
width: 28px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-subtle);
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-2xs);
padding: 0;
transition:
background 0.15s ease,
color 0.15s ease;
}
.theme-segmented__opt:hover {
color: var(--color-text);
}
.theme-segmented__opt--active {
background: var(--color-accent);
color: var(--color-danger-text);
}
.theme-segmented__opt--active:hover {
color: var(--color-danger-text);
}
.user-menu-admin {
color: var(--color-primary);
}
.user-menu-admin i {
color: var(--color-info-blue);
}
.user-menu-admin:hover {
background: var(--color-info-bg-alt);
}
.user-menu-role-badge {
padding: 0 var(--space-5) var(--space-1);
}
.role-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-2xs);
font-weight: var(--weight-semibold);
padding: var(--space-0-5) var(--space-2-5);
border-radius: var(--radius-xl);
}
.role-badge-admin {
background: var(--color-info-surface);
color: var(--color-primary);
}
.role-badge i {
font-size: 10px;
}
.user-menu-logout {
color: var(--color-danger-alt);
margin-bottom: var(--space-1);
}
.user-menu-logout i {
color: var(--color-danger-alt);
}
.user-menu-logout:hover {
background: var(--color-danger-lighter);
}
+1 -1
View File
@@ -1,7 +1,7 @@
/** Display helpers shared across list views. */
/**
* Map a legacy `icon_class` (e.g. "fas fa-folder", "fa-file-pdf") to an icon
* Map an `icon_class` (e.g. "fas fa-folder", "fa-file-pdf") to an icon
* registry name (the FA token without the `fa-` prefix).
*/
export function iconNameFromClass(iconClass: string | undefined | null): string {
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { hashUrlToPath } from './hashRedirect';
describe('hashUrlToPath', () => {
it('maps root and files', () => {
expect(hashUrlToPath('#/')).toBe('/files');
expect(hashUrlToPath('#/files')).toBe('/files');
});
it('maps folder deep links to the new path', () => {
expect(hashUrlToPath('#/files/folder/abc')).toBe('/files/abc');
expect(hashUrlToPath('#/files/folder/abc/def')).toBe('/files/abc/def');
});
it('maps the named sections', () => {
expect(hashUrlToPath('#/shared')).toBe('/shared');
expect(hashUrlToPath('#/sharedwithme')).toBe('/shared-with-me');
expect(hashUrlToPath('#/recent')).toBe('/recent');
expect(hashUrlToPath('#/favorites')).toBe('/favorites');
expect(hashUrlToPath('#/trash')).toBe('/trash');
expect(hashUrlToPath('#/photos')).toBe('/photos');
expect(hashUrlToPath('#/music')).toBe('/music');
});
it('ignores query strings in the hash', () => {
expect(hashUrlToPath('#/recent?foo=bar')).toBe('/recent');
});
it('returns null for non-legacy or unknown hashes', () => {
expect(hashUrlToPath('')).toBeNull();
expect(hashUrlToPath('#section')).toBeNull();
expect(hashUrlToPath('#/unknown')).toBeNull();
});
});
@@ -1,10 +1,10 @@
/**
* Translate a legacy hash route (`#/...` from the vanilla app) into the new
* SvelteKit path, so old bookmarks and external links keep working.
* Translate an old hash route (`#/...` from the vanilla frontend) into the new
* SvelteKit path, so existing bookmarks and external links keep working.
*
* Returns the new pathname, or null when the hash isn't a legacy route.
* Returns the new pathname, or null when the hash isn't a recognised route.
*/
export function legacyHashToPath(hash: string): string | null {
export function hashUrlToPath(hash: string): string | null {
if (!hash.startsWith('#/')) return null;
const raw = hash.slice(1); // drop the '#'
const [pathPart] = raw.split('?');
+38
View File
@@ -0,0 +1,38 @@
/**
* Downscale an image File to a data URI for avatar upload. Ported from the
* original utils/imageResize.js: never upscales, caps the longest edge at
* `maxDim`, prefers WebP with a JPEG fallback.
*/
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result as string);
fr.onerror = () => reject(fr.error);
fr.readAsDataURL(file);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
}
export async function resizeImageToDataUrl(file: File, maxDim = 512): Promise<string> {
const dataUrl = await readAsDataUrl(file);
const img = await loadImage(dataUrl);
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return dataUrl;
ctx.drawImage(img, 0, 0, w, h);
const webp = canvas.toDataURL('image/webp', 0.85);
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.85);
}
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest';
import { legacyHashToPath } from './legacyHash';
describe('legacyHashToPath', () => {
it('maps root and files', () => {
expect(legacyHashToPath('#/')).toBe('/files');
expect(legacyHashToPath('#/files')).toBe('/files');
});
it('maps folder deep links to the new path', () => {
expect(legacyHashToPath('#/files/folder/abc')).toBe('/files/abc');
expect(legacyHashToPath('#/files/folder/abc/def')).toBe('/files/abc/def');
});
it('maps the named sections', () => {
expect(legacyHashToPath('#/shared')).toBe('/shared');
expect(legacyHashToPath('#/sharedwithme')).toBe('/shared-with-me');
expect(legacyHashToPath('#/recent')).toBe('/recent');
expect(legacyHashToPath('#/favorites')).toBe('/favorites');
expect(legacyHashToPath('#/trash')).toBe('/trash');
expect(legacyHashToPath('#/photos')).toBe('/photos');
expect(legacyHashToPath('#/music')).toBe('/music');
});
it('ignores query strings in the hash', () => {
expect(legacyHashToPath('#/recent?foo=bar')).toBe('/recent');
});
it('returns null for non-legacy or unknown hashes', () => {
expect(legacyHashToPath('')).toBeNull();
expect(legacyHashToPath('#section')).toBeNull();
expect(legacyHashToPath('#/unknown')).toBeNull();
});
});