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
+1 -1
View File
@@ -50,7 +50,7 @@ COPY templates templates
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# The SPA is built by the frontend stage; bring it in for the runtime copy below.
# (build.rs no longer generates static-dist unless OXICLOUD_LEGACY_ASSETS=1.)
# (build.rs no longer generates static-dist unless OXICLOUD_RUST_ASSETS=1.)
COPY --from=frontend /static-dist ./static-dist
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
+6 -6
View File
@@ -40,15 +40,15 @@ fn main() {
println!("cargo:rerun-if-changed=static");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=OXICLOUD_LEGACY_ASSETS");
println!("cargo:rerun-if-env-changed=OXICLOUD_RUST_ASSETS");
git_status();
// Post-cutover (Svelte/Vite): the frontend is built by Vite into
// `static-dist/` and the Rust web layer serves it directly — no `include_str!`
// HTML, no Rust-side bundling. The legacy pure-Rust asset pipeline below is
// retained, behind `OXICLOUD_LEGACY_ASSETS=1`, for one-release rollback only.
if env_or("OXICLOUD_LEGACY_ASSETS", "0") != "1" {
// The frontend is built by Vite into `static-dist/` and the Rust web layer
// serves it directly — no `include_str!` HTML, no Rust-side bundling. The
// pure-Rust asset pipeline below is retained, behind `OXICLOUD_RUST_ASSETS=1`,
// for one-release rollback only.
if env_or("OXICLOUD_RUST_ASSETS", "0") != "1" {
return;
}
+4 -2
View File
@@ -7,7 +7,9 @@ package-lock.json
src/lib/i18n/locales/
# vendored / generated — kept byte-faithful to their source
src/lib/styles/base/
src/lib/styles/legacy/
src/lib/styles/legacy.css
src/lib/styles/ported/
src/lib/styles/ported.css
src/lib/icons/registry.ts
static/locales/
static/vendors/
static/workers/
+2 -2
View File
@@ -4,5 +4,5 @@ node_modules/
# Ported verbatim from static/css/base — treated as vendored design tokens.
# New component styles (Svelte <style> + app.css) still get the full ruleset.
src/lib/styles/base/
src/lib/styles/legacy/
src/lib/styles/legacy.css
src/lib/styles/ported/
src/lib/styles/ported.css
+3 -1
View File
@@ -27,6 +27,8 @@ export default ts.config(
}
},
{
ignores: ['build/', '.svelte-kit/', 'package/']
// `static/` holds vendored, verbatim assets (the delta-upload worker and
// the wasm-bindgen hash glue) — lint them as the upstream ships them.
ignores: ['build/', '.svelte-kit/', 'package/', 'static/']
}
);
+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();
});
});
+5 -3
View File
@@ -4,9 +4,10 @@
import { onMount } from 'svelte';
import '$lib/styles/app.css';
import AppShell from '$lib/components/AppShell.svelte';
import DialogHost from '$lib/components/DialogHost.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { session } from '$lib/stores/session.svelte';
import { legacyHashToPath } from '$lib/utils/legacyHash';
import { hashUrlToPath } from '$lib/utils/hashRedirect';
let { children } = $props();
@@ -20,9 +21,9 @@
let ready = $state(false);
onMount(async () => {
// Redirect legacy `#/...` bookmarks to the new path before anything else.
// Redirect old `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
const mapped = legacyHashToPath(location.hash);
const mapped = hashUrlToPath(location.hash);
if (mapped) await goto(mapped, { replaceState: true });
}
await session.load();
@@ -51,6 +52,7 @@
{/if}
<Toaster />
<DialogHost />
<style>
.app-loading {
File diff suppressed because it is too large Load Diff
+82 -4
View File
@@ -1,17 +1,45 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { decideDevice, lookupDeviceCode, type DeviceInfo } from '$lib/api/endpoints/device';
import {
decideDevice,
DeviceLookupFailure,
lookupDeviceCode,
type DeviceInfo
} from '$lib/api/endpoints/device';
import { t } from '$lib/i18n/index.svelte';
type Step = 'code' | 'loading' | 'review' | 'approved' | 'denied' | 'error';
// A complete user-code is 8 chars + a hyphen (e.g. ABCD-1234) → length 9.
const FULL_CODE_LENGTH = 9;
let code = $state(page.url.searchParams.get('code') ?? '');
let step = $state<Step>('code');
let info = $state<DeviceInfo | null>(null);
let errorText = $state('');
let busy = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let codeInput = $state<HTMLInputElement | null>(null);
function failureMessage(err: unknown): string {
if (err instanceof DeviceLookupFailure) {
switch (err.kind) {
case 'unauthorized':
return t(
'device.unauthorized',
'You must be logged in to authorize a device. Please log in first.'
);
case 'not-found':
return t('device.not_found', 'Code not found or expired. Please check and try again.');
default:
return t('device.lookup_failed', 'Failed to verify code. Please try again.');
}
}
return err instanceof Error ? err.message : String(err);
}
async function lookup(e?: SubmitEvent) {
e?.preventDefault();
if (!code) return;
@@ -21,11 +49,34 @@
info = await lookupDeviceCode(code);
step = 'review';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
errorText = failureMessage(err);
step = 'error';
}
}
/**
* Normalise the code field as the user types: uppercase, strip anything
* that isn't [A-Z0-9-], auto-insert the hyphen after the first 4 chars,
* then debounce a lookup once a full code is present.
*/
function onCodeInput(e: Event) {
const target = e.currentTarget as HTMLInputElement;
let val = target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '');
if (val.length === 4 && !val.includes('-')) val = `${val}-`;
code = val;
// Reflect the normalised value back into the input.
target.value = val;
if (step === 'error') {
step = 'code';
errorText = '';
}
clearTimeout(debounceTimer);
if (val.length >= FULL_CODE_LENGTH) {
debounceTimer = setTimeout(() => void lookup(), 300);
}
}
async function decide(action: 'approve' | 'deny') {
busy = true;
try {
@@ -39,8 +90,16 @@
}
}
function backToCode() {
step = 'code';
errorText = '';
// Re-focus so the user can correct the code immediately.
queueMicrotask(() => codeInput?.focus());
}
onMount(() => {
if (code) void lookup();
else codeInput?.focus();
});
</script>
@@ -48,13 +107,32 @@
<main class="device">
<div class="device__card">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'}
<form onsubmit={lookup}>
<label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input bind:value={code} autocomplete="off" inputmode="text" />
<input
bind:this={codeInput}
value={code}
oninput={onCodeInput}
autocomplete="off"
autocapitalize="characters"
spellcheck="false"
inputmode="text"
maxlength={FULL_CODE_LENGTH}
/>
</label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
</form>
@@ -83,7 +161,7 @@
<p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p>
<button onclick={() => (step = 'code')}>{t('common.retry', 'Try again')}</button>
<button onclick={backToCode}>{t('common.retry', 'Try again')}</button>
{/if}
</div>
</main>
+293 -47
View File
@@ -1,39 +1,272 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel,
type FavoritesResourceItem
} from '$lib/api/endpoints/favorites';
import { fileDownloadUrl } from '$lib/api/endpoints/files';
import { renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import FileViewer from '$lib/components/FileViewer.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
type ContextAction,
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<FavoritesResourceItem[]>([]);
let raw = $state<FavoritesResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state('');
let reversed = $state(false);
let ownerNames = $state<Record<string, string>>({});
async function load(reset = false) {
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
const ownerId = it.resource.owner_id ?? null;
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
date: it.favorited_at,
ownerId,
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
isFavorite: true,
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.resource.modified_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'owner',
label: t('groupby.owner', 'Owner'),
orderBy: 'owner',
bucketOf: (e) => e.ownerId ?? null,
labelOf: (id) => ownerNames[id] ?? id
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'favoriteDate',
label: t('groupby.favoriteDate', 'Favorite date'),
orderBy: 'favorited_at',
bucketOf: (e) => dateBucket(e.date)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
async function resolveOwners(items: FavoritesResourceItem[]) {
const ids = [
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
];
await Promise.all(
ids.map(async (id) => {
if (ownerNames[id]) return;
const name = await resolveOwnerName(id);
ownerNames = { ...ownerNames, [id]: name };
})
);
}
async function load(reset = false, orderBy = 'name', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchFavoritesPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchFavoritesPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
void resolveOwners(page.items);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('favorites: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
async function unfavorite(item: FavoritesResourceItem) {
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'name';
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
async function unfavorite(entry: ResourceEntry) {
try {
await removeFavorite(item.resource_type, item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await removeFavorite(entry.kind, entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
// ── Context-menu actions ──────────────────────────────────────────────────
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
let moveItems = $state<{ id: string; name: string; kind: 'file' | 'folder' }[] | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: 'file' | 'folder' } | null>(null);
async function rename(entry: ResourceEntry) {
const name = await promptDialog({
title: t('common.rename', 'Rename'),
defaultValue: entry.name,
confirmText: t('common.rename', 'Rename')
});
if (!name || name === entry.name) return;
try {
if (entry.kind === 'file') await renameFile(entry.id, name);
else await renameFolder(entry.id, name);
await load(true, orderByForGroup());
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function remove(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
if (entry.kind === 'file') await deleteFile(entry.id);
else await deleteFolder(entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function downloadEntry(entry: ResourceEntry) {
if (entry.kind !== 'file') return;
const a = document.createElement('a');
a.href = fileDownloadUrl(entry.id);
a.download = entry.name;
document.body.appendChild(a);
a.click();
a.remove();
}
const contextActions: ContextAction[] = [
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
run: downloadEntry
},
{
key: 'share',
label: t('files.share', 'Share'),
icon: 'share-alt',
run: (e) => {
shareTarget = { id: e.id, name: e.name, kind: e.kind };
shareOpen = true;
}
},
{
key: 'move',
label: t('files.move', 'Move'),
icon: 'arrows-alt',
run: (e) => {
moveItems = null;
moveTarget = { id: e.id, name: e.name, kind: e.kind };
moveOpen = true;
}
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
let selectedIds = $state<Set<string>>(new Set());
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
function batchTargets() {
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
}
function batchDownload() {
for (const e of selectedEntries) downloadEntry(e);
}
async function batchDelete() {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t(
'files.confirm_delete_n',
{ count: selectedEntries.length },
'Delete {{count}} item(s)?'
),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
);
const removed = new Set(selectedEntries.map((e) => e.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
selectedIds = new Set();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
@@ -44,45 +277,58 @@
<svelte:head><title>{t('nav.favorites', 'Favorites')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.favorites', 'Favorites')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.favorites', 'Favorites')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('favorites.empty', 'No favorites yet.')}
emptyIcon="star"
emptyText={t('favorites.empty_state', 'No favorites yet')}
emptyHint={t('favorites.empty_hint', 'Star files and folders to find them here quickly')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={unfavorite}
showOwner
selectable
{contextActions}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
onselectionchange={(ids) => (selectedIds = ids)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.favorited_at)}
{#snippet batchToolbar()}
<button class="btn btn-secondary" onclick={batchDownload}>
<Icon name="download" />
{t('common.download', 'Download')}
</button>
<button
class="btn btn-secondary"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
>
{#snippet actions()}
<button class="link-btn" onclick={() => unfavorite(item)}>
{t('favorites.remove', 'Remove')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<button class="btn btn-danger" onclick={batchDelete}>
<Icon name="trash" />
{t('common.delete', 'Delete')}
</button>
{/snippet}
</ResourceList>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
items={moveItems}
onmoved={() => {
selectedIds = new Set();
load(true, orderByForGroup());
}}
/>
<ShareDialog bind:open={shareOpen} item={shareTarget} />
File diff suppressed because it is too large Load Diff
+345 -39
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addGroupMember,
addUserMember,
createGroup,
deleteGroup,
listGroups,
groupDisplayName,
groupIconName,
INTERNAL_GROUP_ID,
listGroupsPage,
listMembers,
removeGroupMember,
removeUserMember,
@@ -12,20 +16,43 @@
type GroupItem,
type GroupMember
} from '$lib/api/endpoints/groups';
import {
ensureResolvers,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import { ui } from '$lib/stores/ui.svelte';
const PAGE_SIZE = 50;
let groups = $state<GroupItem[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let expandedId = $state<string | null>(null);
let members = $state<GroupMember[]>([]);
let resolverReady = $state(false);
const hasMore = $derived(groups.length < total);
// Add-member combobox state (scoped to the expanded group)
let addQuery = $state('');
let addResults = $state<Recipient[]>([]);
let addBusy = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function load() {
loading = true;
error = null;
try {
groups = await listGroups();
const page = await listGroupsPage(PAGE_SIZE, 0);
groups = page.items;
total = page.total;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
@@ -33,11 +60,42 @@
}
}
async function loadMore() {
loadingMore = true;
try {
const page = await listGroupsPage(PAGE_SIZE, groups.length);
groups = [...groups, ...page.items];
total = page.total;
} catch (e) {
report(e);
} finally {
loadingMore = false;
}
}
function report(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
/** Localised "(N members)" label. Project i18n has no plural rules, so we
* branch on the three named forms ourselves. */
function memberCountLabel(count: number): string {
if (count === 0) return t('groups.member_count_zero', 'no members');
if (count === 1) return t('groups.member_count_one', '1 member');
return t('groups.member_count_other', { count }, '{{count}} members');
}
/** Display info for a member row; falls back to the raw id until caches load. */
function memberInfo(m: GroupMember): { label: string; sublabel?: string } {
// resolverReady gates re-resolution once the caches are populated.
void resolverReady;
const r = resolveRecipient(m.kind, m.id);
return { label: r.label, sublabel: r.sublabel };
}
async function expand(g: GroupItem) {
addQuery = '';
addResults = [];
if (expandedId === g.id) {
expandedId = null;
return;
@@ -52,10 +110,14 @@
}
async function onCreate() {
const name = prompt(t('groups.new_prompt', 'New group name'));
if (!name) return;
const name = await promptDialog({
title: t('groups.create_dialog_title', 'New group'),
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.create', 'Create')
});
if (!name || !name.trim()) return;
try {
await createGroup(name);
await createGroup(name.trim());
await load();
} catch (e) {
report(e);
@@ -63,10 +125,15 @@
}
async function onRename(g: GroupItem) {
const name = prompt(t('groups.rename_prompt', 'New name'), g.name);
if (!name || name === g.name) return;
const name = await promptDialog({
title: t('groups.edit_dialog_title', 'Rename group'),
defaultValue: g.name,
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.rename', 'Rename')
});
if (!name || !name.trim() || name.trim() === g.name) return;
try {
await renameGroup(g.id, name);
await renameGroup(g.id, name.trim());
await load();
} catch (e) {
report(e);
@@ -74,7 +141,25 @@
}
async function onDelete(g: GroupItem) {
if (!confirm(t('groups.confirm_delete', { name: g.name }, 'Delete group "{{name}}"?'))) return;
// Typed-name confirmation: the user must type the exact group name.
const typed = await promptDialog({
title: t('groups.delete_group', 'Delete group'),
message: t(
'groups.delete_confirm',
{ name: g.name },
'Delete the group "{{name}}"? Type the group name to confirm.'
),
placeholder: g.name,
confirmText: t('actions.delete', 'Delete')
});
if (typed === null) return;
if (typed !== g.name) {
ui.notify(
t('groups.delete_confirm_mismatch', 'Type the group name exactly to confirm.'),
'error'
);
return;
}
try {
await deleteGroup(g.id);
if (expandedId === g.id) expandedId = null;
@@ -84,11 +169,34 @@
}
}
async function onAddMember(g: GroupItem) {
const userId = prompt(t('groups.add_member_prompt', 'User ID to add'));
if (!userId) return;
function onAddQuery() {
if (searchTimer) clearTimeout(searchTimer);
const q = addQuery;
if (!q.trim()) {
addResults = [];
return;
}
searchTimer = setTimeout(async () => {
addBusy = true;
try {
const all = await searchRecipients(q);
// Don't offer the group as a member of itself, or current members.
const existing = new Set(members.map((m) => m.id));
addResults = all.filter((r) => r.id !== expandedId && !existing.has(r.id));
} catch (e) {
report(e);
} finally {
addBusy = false;
}
}, 200);
}
async function pickMember(g: GroupItem, r: Recipient) {
try {
await addUserMember(g.id, userId);
if (r.type === 'group') await addGroupMember(g.id, r.id);
else await addUserMember(g.id, r.id);
addQuery = '';
addResults = [];
members = await listMembers(g.id);
} catch (e) {
report(e);
@@ -97,15 +205,23 @@
async function onRemoveMember(groupId: string, m: GroupMember) {
try {
if (m.user_id) await removeUserMember(groupId, m.user_id);
else if (m.group_id) await removeGroupMember(groupId, m.group_id);
if (m.kind === 'user') await removeUserMember(groupId, m.id);
else await removeGroupMember(groupId, m.id);
members = await listMembers(groupId);
} catch (e) {
report(e);
}
}
onMount(load);
onMount(async () => {
await load();
try {
await ensureResolvers();
resolverReady = true;
} catch {
/* names fall back to ids */
}
});
</script>
<svelte:head><title>{t('nav.groups', 'Groups')} · OxiCloud</title></svelte:head>
@@ -129,40 +245,101 @@
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
{g.name}
{#if g.member_count != null}<span class="muted">({g.member_count})</span>{/if}
<span class="avatar"><Icon name={groupIconName(g)} /></span>
<span class="group__text">
<span class="group__title">
{groupDisplayName(g)}
{#if g.is_virtual}<span class="badge badge--system"
>{t('groups.virtual_badge', 'System')}</span
>{/if}
</span>
{#if g.description}<span class="muted">{g.description}</span>{/if}
{#if !g.is_virtual && g.member_count != null}
<span class="muted">{memberCountLabel(g.member_count)}</span>
{/if}
</span>
</button>
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{/if}
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
<button class="link-btn" onclick={() => onAddMember(g)}>
{t('groups.add_member', 'Add member')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="add-member">
<input
class="add-member__input"
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
bind:value={addQuery}
oninput={onAddQuery}
/>
{#if addBusy}
<p class="muted">{t('common.loading', 'Loading…')}</p>
{:else if addResults.length > 0}
<ul class="add-member__results">
{#each addResults as r (r.type + r.id)}
<li>
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
<span class="avatar avatar--sm">
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">{r.label}</span>
{#if r.sublabel}<span class="muted">{r.sublabel}</span>{/if}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else if g.id === INTERNAL_GROUP_ID}
<p class="muted">
{t('groups.virtual_internal_explanation', 'Every internal user on this server.')}
</p>
{/if}
{#if members.length === 0}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{#if !(g.id === INTERNAL_GROUP_ID)}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{/if}
{:else}
<ul class="members__list">
{#each members as m (m.user_id ?? m.group_id)}
<li>
<span>{m.email ?? m.name ?? m.user_id ?? m.group_id}</span>
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{#each members as m (m.kind + m.id)}
{@const info = memberInfo(m)}
<li class="vignette">
<span class="avatar avatar--sm">
<Icon name={m.kind === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">
{info.label}
{#if m.kind === 'group'}<span class="badge badge--nested"
>{t('groups.nested', 'Group')}</span
>{/if}
</span>
{#if info.sublabel}<span class="muted">{info.sublabel}</span>{/if}
</span>
{#if g.can_manage !== false && !g.is_virtual}
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{/if}
</li>
{/each}
</ul>
@@ -172,6 +349,12 @@
</li>
{/each}
</ul>
{#if hasMore}
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
</button>
{/if}
{/if}
</main>
@@ -216,19 +399,40 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem;
gap: 0.5rem;
}
.group__name {
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
text-align: left;
flex: 1;
min-width: 0;
}
.group__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.group__title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.group__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.members {
@@ -262,6 +466,104 @@
justify-content: space-between;
}
.vignette {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
}
.vignette__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.vignette__name {
display: flex;
align-items: center;
gap: var(--space-2);
}
.avatar {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-accent-bg, var(--color-bg-muted));
color: var(--color-accent-text, var(--color-text));
font-size: var(--text-xs, 0.75rem);
flex-shrink: 0;
}
.avatar--sm {
width: 1.6rem;
height: 1.6rem;
}
.badge {
display: inline-block;
padding: 0.05rem 0.4rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
}
.badge--system {
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.badge--nested {
background: var(--color-info-bg);
color: var(--color-info-text);
}
.add-member {
position: relative;
margin: var(--space-2) 0;
}
.add-member__input {
width: 100%;
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);
}
.add-member__results {
list-style: none;
margin: var(--space-1) 0 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
max-height: 16rem;
overflow: auto;
}
.add-member__opt {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
text-align: left;
}
.add-member__opt:hover {
background: var(--color-bg-hover);
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
@@ -292,6 +594,10 @@
border-color: transparent;
}
.load-more {
align-self: center;
}
.link-btn {
background: none;
border: none;
+585 -48
View File
@@ -1,29 +1,97 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { login } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
import { onMount } from 'svelte';
import {
exchangeOidcCode,
fetchMe,
getAuthStatus,
getOidcProviders,
login,
register,
sendMagicLink,
setupAdmin,
type OidcProviders
} from '$lib/api/endpoints/auth';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
type Mode = 'login' | 'register' | 'setup';
let mode = $state<Mode>('login');
// First-run admin setup is only offered after the status probe confirms it.
let setupAvailable = $state(false);
// Suppress the auth UI until the onMount probes (session/oidc/status) settle,
// to avoid flashing the login form before a redirect or the setup wizard.
let booting = $state(true);
// Login
let username = $state('');
let password = $state('');
let showPassword = $state(false);
let capsOn = $state(false);
let error = $state('');
let busy = $state(false);
// Register
let regUsername = $state('');
let regEmail = $state('');
let regPassword = $state('');
let regConfirm = $state('');
let regError = $state('');
let regSuccess = $state('');
let regShowPassword = $state(false);
let regShowConfirm = $state(false);
let regCapsOn = $state(false);
// Admin setup (first run)
let setupEmail = $state('');
let setupPassword = $state('');
let setupConfirm = $state('');
let setupShowPassword = $state(false);
let setupShowConfirm = $state(false);
let setupCapsOn = $state(false);
let setupError = $state('');
let setupSuccess = $state('');
const setupMatchState = $derived(
setupConfirm.length === 0 ? '' : setupPassword === setupConfirm ? 'ok' : 'bad'
);
// Magic link
let magicOpen = $state(false);
let magicEmail = $state('');
let magicStatus = $state<{ text: string; ok: boolean } | null>(null);
// OIDC
let oidc = $state<OidcProviders>({ enabled: false });
const passwordLoginEnabled = $derived(oidc.password_login_enabled !== false);
const redirectTarget = $derived(page.url.searchParams.get('redirect') || '/files');
const matchState = $derived(
regConfirm.length === 0 ? '' : regPassword === regConfirm ? 'ok' : 'bad'
);
function csrfCookiePresent(): boolean {
return document.cookie.split('; ').some((c) => c.startsWith('oxicloud_csrf='));
}
async function onsubmit(e: SubmitEvent) {
function onPwKey(e: KeyboardEvent) {
capsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onRegPwKey(e: KeyboardEvent) {
regCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
function onSetupPwKey(e: KeyboardEvent) {
setupCapsOn = e.getModifierState?.('CapsLock') ?? false;
}
async function onLogin(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const data = await login(username, password);
// Tokens are HttpOnly cookies set by the server. Verify the browser
// actually accepted them: the non-HttpOnly CSRF cookie must be present.
if (!csrfCookiePresent()) {
error = t(
'auth.cookie_rejected',
@@ -39,6 +107,121 @@
busy = false;
}
}
async function onRegister(e: SubmitEvent) {
e.preventDefault();
regError = '';
regSuccess = '';
if (regPassword !== regConfirm) {
regError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
await register(regUsername, regEmail, regPassword);
regSuccess = t('auth.account_success', 'Account created. You can now sign in.');
regUsername = regEmail = regPassword = regConfirm = '';
setTimeout(() => (mode = 'login'), 2000);
} catch (err) {
regError =
err instanceof Error ? err.message : t('auth.register_error', 'Registration failed');
} finally {
busy = false;
}
}
async function onSetup(e: SubmitEvent) {
e.preventDefault();
setupError = '';
setupSuccess = '';
if (setupPassword !== setupConfirm) {
setupError = t('auth.passwords_mismatch', 'Passwords do not match');
return;
}
busy = true;
try {
await setupAdmin(setupEmail, setupPassword);
setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.');
setupEmail = setupPassword = setupConfirm = '';
// Admin now exists — fold the setup affordance away and return to login.
setupAvailable = false;
setTimeout(() => {
mode = 'login';
setupSuccess = '';
}, 2000);
} catch (err) {
setupError =
err instanceof Error ? err.message : t('auth.admin_create_error', 'Setup failed');
} finally {
busy = false;
}
}
async function onMagicLink(e: SubmitEvent) {
e.preventDefault();
if (!magicEmail) return;
magicStatus = null;
busy = true;
try {
const result = await sendMagicLink(magicEmail);
magicStatus =
result === 'sent'
? {
text: t(
'auth.magic_sent',
'If an account exists, a sign-in link has been sent. Check your inbox.'
),
ok: true
}
: {
text: t(
'auth.magic_unavailable',
'Sign-in by email is not available on this server.'
),
ok: false
};
if (result === 'sent') magicEmail = '';
} catch {
magicStatus = { text: t('auth.magic_error', 'Something went wrong. Try again.'), ok: false };
} finally {
busy = false;
}
}
onMount(async () => {
// 1) OIDC code-exchange fallback: the IdP round-trip may land back here
// with ?oidc_code=. Exchange it for a session and redirect into the app.
const oidcCode = page.url.searchParams.get('oidc_code');
if (oidcCode) {
const user = await exchangeOidcCode(oidcCode);
if (user) {
session.user = user;
await goto(redirectTarget, { replaceState: true });
return;
}
// Exchange failed — fall through to the normal login UI.
}
// 2) Existing-session probe: if already authenticated, skip the form.
try {
const me = await fetchMe();
if (me) {
session.user = me;
await goto(redirectTarget, { replaceState: true });
return;
}
} catch {
/* probe failed — show the login page */
}
// 3) Bootstrap probe: a fresh install (no admin) must be set up first.
const [providers, status] = await Promise.all([getOidcProviders(), getAuthStatus()]);
oidc = providers;
setupAvailable = !status.initialized;
if (setupAvailable) mode = 'setup';
booting = false;
});
</script>
<svelte:head>
@@ -55,53 +238,407 @@
/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1 class="auth-title">{t('auth.sign_in', 'Sign in')}</h1>
{#if booting}
<p class="auth-subtitle">{t('common.loading', 'Loading…')}</p>
{:else}
<h1 class="auth-title">
{#if mode === 'login'}
{t('auth.sign_in', 'Sign in')}
{:else if mode === 'register'}
{t('auth.register', 'Create account')}
{:else}
{t('auth.setup_title', 'Initial setup')}
{/if}
</h1>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{/if}
{#if mode === 'login'}
{#if passwordLoginEnabled}
{#if error}<div class="auth-error" style="display: block" role="alert">{error}</div>{/if}
<form class="auth-form" onsubmit={onLogin} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="login-password"
class="auth-input"
type={showPassword ? 'text' : 'password'}
bind:value={password}
onkeydown={onPwKey}
onkeyup={onPwKey}
autocomplete="current-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={showPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (showPassword = !showPassword)}
></button>
</div>
{#if capsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
<button class="auth-magic-toggle" onclick={() => (magicOpen = !magicOpen)}>
{t('auth.magic_prompt', 'No password? Sign in with an email link')}
</button>
{#if magicOpen}
<div class="auth-magic-reveal">
<p class="auth-hint">
{t(
'auth.magic_hint',
"No password? Enter your email and we'll send you a one-time sign-in link."
)}
</p>
<form class="auth-form" onsubmit={onMagicLink}>
<div class="auth-input-group">
<label class="auth-label" for="magic-email">
{t('auth.magic_email_label', 'Email address')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="magic-email"
class="auth-input"
type="email"
bind:value={magicEmail}
autocomplete="email"
placeholder={t('auth.email', 'you@example.com')}
/>
</div>
</div>
<button class="auth-button auth-button-secondary" type="submit" disabled={busy}>
{t('auth.magic_send', 'Send link')}
</button>
</form>
{#if magicStatus}
<div
class={magicStatus.ok
? 'auth-status auth-status-success'
: 'auth-status auth-status-error'}
>
{magicStatus.text}
</div>
{/if}
</div>
{/if}
{/if}
{#if oidc.enabled}
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<a class="auth-button auth-button-oidc" href={oidc.authorize_endpoint}>
{t(
'auth.sso_login_provider',
{ provider: oidc.provider_name ?? 'SSO' },
'Sign in with {{provider}}'
)}
</a>
{/if}
{#if passwordLoginEnabled}
<div class="auth-toggle">
{t('auth.no_account', 'No account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'register')}>
{t('auth.register', 'Create one')}
</button>
</div>
{/if}
{#if setupAvailable}
<div class="auth-toggle">
{t('auth.admin_setup', 'First time?')}
<button class="auth-toggle-link" onclick={() => (mode = 'setup')}>
{t('auth.setup', 'Set up administrator')}
</button>
</div>
{/if}
{:else if mode === 'register'}
{#if regError}<div class="auth-error" style="display: block" role="alert">
{regError}
</div>{/if}
{#if regSuccess}<div class="auth-success" style="display: block">{regSuccess}</div>{/if}
<form class="auth-form" onsubmit={onRegister} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="reg-username">{t('auth.username', 'Username')}</label>
<input
id="reg-username"
class="auth-input"
bind:value={regUsername}
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-email">{t('auth.email', 'Email')}</label>
<input
id="reg-email"
class="auth-input"
type="email"
bind:value={regEmail}
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-password">{t('auth.password', 'Password')}</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-password"
class="auth-input"
type={regShowPassword ? 'text' : 'password'}
bind:value={regPassword}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowPassword = !regShowPassword)}
></button>
</div>
{#if regCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="reg-confirm"
>{t('auth.confirm_password', 'Confirm password')}</label
>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="reg-confirm"
class="auth-input"
type={regShowConfirm ? 'text' : 'password'}
bind:value={regConfirm}
onkeydown={onRegPwKey}
onkeyup={onRegPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={regShowConfirm}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (regShowConfirm = !regShowConfirm)}
></button>
</div>
{#if matchState}
<div
class="auth-match show {matchState === 'ok' ? 'auth-match--ok' : 'auth-match--bad'}"
>
{matchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{t('auth.register', 'Create account')}
</button>
</form>
<div class="auth-toggle">
{t('auth.have_account', 'Already have an account?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{:else}
<div class="setup-steps">
<div class="setup-step">
<div class="step-number active">1</div>
<div class="step-title active">{t('auth.setup_step1', 'Admin')}</div>
</div>
<div class="setup-step">
<div class="step-number">2</div>
<div class="step-title">{t('auth.setup_step2', 'System')}</div>
</div>
<div class="setup-step">
<div class="step-number">3</div>
<div class="step-title">{t('auth.setup_step3', 'Completed')}</div>
</div>
</div>
{#if setupError}<div class="auth-error" style="display: block" role="alert">
{setupError}
</div>{/if}
{#if setupSuccess}<div class="auth-success" style="display: block">{setupSuccess}</div>{/if}
<form class="auth-form" onsubmit={onSetup} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="setup-username">
{t('auth.admin_username', 'Administrator username')}
</label>
<div class="auth-input-wrap auth-input-wrap--user">
<input id="setup-username" class="auth-input" type="text" value="admin" readonly />
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-email">
{t('auth.admin_email', 'Administrator email')}
</label>
<div class="auth-input-wrap auth-input-wrap--mail">
<input
id="setup-email"
class="auth-input"
type="email"
bind:value={setupEmail}
autocomplete="email"
required
disabled={busy}
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-password">
{t('auth.admin_password', 'Administrator password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-password"
class="auth-input"
type={setupShowPassword ? 'text' : 'password'}
bind:value={setupPassword}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
minlength="8"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowPassword}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowPassword = !setupShowPassword)}
></button>
</div>
{#if setupCapsOn}
<div class="auth-caps-warning">{t('auth.caps_lock', 'Caps Lock is on')}</div>
{/if}
</div>
<div class="auth-input-group">
<label class="auth-label" for="setup-confirm">
{t('auth.confirm_password', 'Confirm password')}
</label>
<div class="auth-input-wrap auth-input-wrap--lock has-toggle">
<input
id="setup-confirm"
class="auth-input"
type={setupShowConfirm ? 'text' : 'password'}
bind:value={setupConfirm}
onkeydown={onSetupPwKey}
onkeyup={onSetupPwKey}
autocomplete="new-password"
required
disabled={busy}
/>
<button
type="button"
class="auth-pw-toggle"
aria-pressed={setupShowConfirm}
aria-label={t('auth.toggle_password', 'Show password')}
onclick={() => (setupShowConfirm = !setupShowConfirm)}
></button>
</div>
{#if setupMatchState}
<div
class="auth-match show {setupMatchState === 'ok'
? 'auth-match--ok'
: 'auth-match--bad'}"
>
{setupMatchState === 'ok'
? t('auth.passwords_match', 'Passwords match')
: t('auth.passwords_mismatch', "Passwords don't match")}
</div>
{/if}
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{t('auth.create_admin', 'Create administrator')}
</button>
</form>
<div class="auth-toggle">
{t('auth.back_to_login', 'Already configured?')}
<button class="auth-toggle-link" onclick={() => (mode = 'login')}>
{t('auth.sign_in', 'Sign in')}
</button>
</div>
{/if}
{/if}
{#if error}
<div class="auth-error" style="display: block" role="alert">{error}</div>
{/if}
<form class="auth-form" {onsubmit} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label>
<input
id="login-password"
class="auth-input"
type="password"
bind:value={password}
autocomplete="current-password"
required
disabled={busy}
/>
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
<div class="auth-lang">
<select
aria-label={t('settings.language', 'Language')}
value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
</div>
</div>
</div>
<style>
.auth-lang {
margin-top: var(--space-5);
text-align: center;
}
.auth-lang select {
padding: var(--space-1) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text-muted);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -3,23 +3,74 @@
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
const reason = $derived(page.url.searchParams.get('reason') ?? '');
type ErrorAction = 'retry' | 'close';
interface ErrorView {
title: string;
message: string;
actionLabel: string;
action: ErrorAction;
}
// Legacy used `?type=`; the rewrite briefly renamed it to `?reason=`. Read
// `type` first and fall back to `reason` so older links keep working.
const errorType = $derived(
page.url.searchParams.get('type') ?? page.url.searchParams.get('reason') ?? 'generic'
);
const view = $derived<ErrorView>(buildView(errorType));
function buildView(type: string): ErrorView {
switch (type) {
case 'invalid-credentials':
return {
title: t('nextcloud.error_invalid_title', 'Login Failed'),
message: t(
'nextcloud.error_invalid_body',
'Invalid username or password. Please check your credentials and try again.'
),
actionLabel: t('common.retry', 'Try Again'),
action: 'retry'
};
case 'session-expired':
return {
title: t('nextcloud.error_expired_title', 'Session Expired'),
message: t('nextcloud.error_expired_body', 'Your session has expired. Please try again.'),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
case 'not-found':
return {
title: t('nextcloud.error_notfound_title', 'Not Found'),
message: t('nextcloud.error_notfound_body', 'The requested page was not found.'),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
default:
return {
title: t('nextcloud.error_title', 'Error'),
message: t(
'nextcloud.error_generic_body',
'An unexpected error occurred. Please try again.'
),
actionLabel: t('nextcloud.close_window', 'Close Window'),
action: 'close'
};
}
}
function onAction() {
if (view.action === 'retry') history.back();
else window.close();
}
</script>
<svelte:head
><title>{t('nextcloud.error_title', 'Something went wrong')} · OxiCloud</title></svelte:head
>
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
<main class="nc-status">
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{t('nextcloud.error_title', 'Something went wrong')}</h1>
<p>
{t(
'nextcloud.error_body',
'The connection could not be completed. Please try again from your application.'
)}
</p>
{#if reason}<p class="nc-status__reason">{reason}</p>{/if}
<h1>{view.title}</h1>
<p>{view.message}</p>
<button type="button" class="nc-status__action" onclick={onAction}>{view.actionLabel}</button>
</main>
<style>
@@ -42,8 +93,13 @@
color: var(--color-danger-text);
}
.nc-status__reason {
color: var(--color-text-muted);
font-size: 0.875rem;
.nc-status__action {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
</style>
@@ -1,7 +1,7 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { getOidcProviders } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
// Nextcloud Login Flow v2. The form does a NATIVE POST to the backend flow
@@ -16,114 +16,80 @@
let passwordLoginEnabled = $state(true);
onMount(async () => {
try {
const resp = await apiFetch('/api/auth/oidc/providers');
if (!resp.ok) return;
const info = (await resp.json()) as {
enabled?: boolean;
provider_name?: string;
password_login_enabled?: boolean;
};
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
} catch {
/* OIDC not available — password-only */
}
const info = await getOidcProviders();
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
});
</script>
<svelte:head><title>{t('app.title', 'OxiCloud')}</title></svelte:head>
<main class="nc">
<div class="nc__card">
<h1>{t('nextcloud.grant_title', 'Grant access')}</h1>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text"><span class="brand-oxi">Oxi</span>Cloud</div>
</div>
<h1 class="auth-title">{t('nextcloud.grant_title', 'Grant access')}</h1>
<p class="auth-subtitle">
{t('nextcloud.grant_subtitle', 'A Nextcloud client is requesting access to your account.')}
</p>
{#if !validToken}
<p class="nc__error">{t('nextcloud.invalid_token', 'Invalid session token.')}</p>
<div class="auth-error" style="display: block" role="alert">
{t('nextcloud.invalid_token', 'Invalid session token.')}
</div>
{:else}
{#if passwordLoginEnabled}
<form method="post" action={formAction} class="nc__form">
<label>
<span>{t('auth.username', 'Username or email')}</span>
<input name="user" type="text" autocomplete="username" required />
</label>
<label>
<span>{t('auth.password', 'Password')}</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">{t('nextcloud.grant', 'Grant access')}</button>
<form class="auth-form" method="post" action={formAction}>
<div class="auth-input-group">
<label class="auth-label" for="nc-user">{t('auth.username', 'Username or email')}</label
>
<div class="auth-input-wrap auth-input-wrap--user">
<input
id="nc-user"
class="auth-input"
name="user"
type="text"
autocomplete="username"
required
/>
</div>
</div>
<div class="auth-input-group">
<label class="auth-label" for="nc-password">{t('auth.password', 'Password')}</label>
<div class="auth-input-wrap auth-input-wrap--lock">
<input
id="nc-password"
class="auth-input"
name="password"
type="password"
autocomplete="current-password"
required
/>
</div>
</div>
<button class="auth-button" type="submit">{t('nextcloud.grant', 'Grant access')}</button>
</form>
{/if}
{#if oidcEnabled}
<div class="nc__oidc">
<a class="nc__sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
</div>
{#if passwordLoginEnabled}
<div class="auth-divider"><span>{t('auth.or', 'or')}</span></div>
{/if}
<a class="auth-button auth-button-sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
{/if}
{/if}
</div>
</main>
<style>
.nc {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.nc__card {
width: min(92vw, 22rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.nc__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
button,
.nc__sso {
display: inline-block;
text-align: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.nc__error {
color: var(--color-danger-text);
}
</style>
</div>
@@ -1,6 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
function closeWindow() {
window.close();
}
onMount(() => {
// Mirror the legacy flow: auto-close the popup shortly after success so
// the user is returned to their Nextcloud client without an extra click.
const timer = setTimeout(closeWindow, 3000);
return () => clearTimeout(timer);
});
</script>
<svelte:head><title>{t('nextcloud.success_title', 'Access granted')} · OxiCloud</title></svelte:head
@@ -10,6 +22,9 @@
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
<button type="button" class="nc-status__action" onclick={closeWindow}>
{t('nextcloud.close_window', 'Close Window')}
</button>
</main>
<style>
@@ -31,4 +46,14 @@
:global(.nc-status__icon--ok) {
color: var(--color-success-text);
}
.nc-status__action {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
</style>
+785 -16
View File
@@ -1,9 +1,19 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { fileInlineUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import {
batchTrash,
fetchFileMetadata,
fetchPhotos,
uploadThumbnail,
type FileMetadata
} from '$lib/api/endpoints/photos';
import { addFavorite } from '$lib/api/endpoints/favorites';
import { deleteFile, fileDownloadUrl, fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let items = $state<FileItem[]>([]);
let cursor = $state<string | null>(null);
@@ -12,6 +22,72 @@
let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | null>(null);
type GroupMode = 'day' | 'month' | 'year';
const GROUP_KEY = 'oxicloud-photos-group';
let groupMode = $state<GroupMode>('month');
let selected = $state<Set<string>>(new Set());
let lightbox = $state(-1); // index into `items`, -1 = closed
/** Client-generated video frame thumbnails (file id → data/URL). */
let videoThumbs = $state<Record<string, string>>({});
function isVideo(p: FileItem): boolean {
return (p.mime_type ?? '').startsWith('video/');
}
/** EXIF-aware timestamp (seconds → ms), matching the OLD grouping logic. */
function ts(p: FileItem): number {
const v = p.sort_date || p.created_at || 0;
return v < 1e12 ? v * 1000 : v;
}
function bucketKey(d: Date): string {
const y = d.getFullYear();
if (groupMode === 'year') return `${y}`;
const m = `${d.getMonth() + 1}`.padStart(2, '0');
if (groupMode === 'month') return `${y}-${m}`;
return `${y}-${m}-${`${d.getDate()}`.padStart(2, '0')}`;
}
function bucketLabel(d: Date): string {
if (groupMode === 'year') return `${d.getFullYear()}`;
if (groupMode === 'month')
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
return d.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
const groups = $derived.by(() => {
const out: Array<{ key: string; label: string; photos: FileItem[] }> = [];
const index = new Map<string, number>();
for (const p of items) {
const d = new Date(ts(p));
const key = bucketKey(d);
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, label: bucketLabel(d), photos: [] });
}
out[i].photos.push(p);
}
return out;
});
function iconUrl(id: string): string {
return `/api/files/${id}/thumbnail/icon`;
}
function previewUrl(id: string): string {
return `/api/files/${id}/thumbnail/preview`;
}
function largeUrl(id: string): string {
return `/api/files/${id}/thumbnail/large`;
}
async function loadMore() {
if (loading || exhausted) return;
loading = true;
@@ -29,7 +105,290 @@
}
}
function setGroupMode(m: GroupMode) {
if (groupMode === m) return;
groupMode = m;
if (typeof localStorage !== 'undefined') localStorage.setItem(GROUP_KEY, m);
}
function toggle(id: string) {
const n = new Set(selected);
if (n.has(id)) n.delete(id);
else n.add(id);
selected = n;
}
/** A plain tile click toggles selection once anything is selected, else opens the lightbox. */
function onTileClick(p: FileItem) {
if (selected.size > 0) toggle(p.id);
else openLightbox(p);
}
function downloadSelected() {
for (const id of selected) {
const a = document.createElement('a');
a.href = fileDownloadUrl(id);
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}
}
async function trashSelected() {
const ids = [...selected];
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photos'),
message: t('photos.confirm_delete', { n: ids.length }, 'Move {{n}} photos to trash?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
const trashed = await batchTrash(ids);
if (trashed.size > 0) {
items = items.filter((p) => !trashed.has(p.id));
const n = new Set(selected);
for (const id of trashed) n.delete(id);
selected = n;
}
if (trashed.size < ids.length) {
ui.notify(
t(
'photos.trash_partial',
{ ok: trashed.size, total: ids.length },
'{{ok}} of {{total}} moved to trash.'
),
'warning'
);
} else {
ui.notify(t('photos.trashed', { n: trashed.size }, '{{n}} moved to trash.'), 'success');
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
// ── Client-side video thumbnail generation ──────────────────────────────
// When the server has no thumbnail for a video tile the <img> errors; we
// then extract a frame with the browser's native decoder and upload it.
async function generateVideoThumb(file: FileItem) {
if (videoThumbs[file.id]) return;
try {
const bitmap = await frameFromVideo(fileInlineUrl(file.id));
const SIZES: Array<['icon' | 'preview' | 'large', number, number]> = [
['icon', 150, 150],
['preview', 400, 400],
['large', 800, 800]
];
let previewData = '';
for (const [size, w, h] of SIZES) {
const blob = await bitmapToBlob(bitmap, w, h);
if (size === 'preview') previewData = await blobToDataUrl(blob);
await uploadThumbnail(file.id, size, blob).catch(() => {});
}
if (previewData) videoThumbs = { ...videoThumbs, [file.id]: previewData };
} catch {
// Keep the generic play badge on failure.
}
}
function frameFromVideo(src: string): Promise<ImageBitmap> {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.src = src;
video.muted = true;
video.preload = 'metadata';
video.onloadedmetadata = () => {
video.currentTime = (video.duration || 3) / 3;
};
video.onseeked = async () => {
try {
const bitmap = await createImageBitmap(video);
video.removeAttribute('src');
video.load();
resolve(bitmap);
} catch (e) {
reject(e instanceof Error ? e : new Error(String(e)));
}
};
video.onerror = () => reject(new Error('video frame extraction failed'));
});
}
async function bitmapToBlob(bitmap: ImageBitmap, tw: number, th: number): Promise<Blob> {
const ratio = bitmap.width / bitmap.height;
const target = tw / th;
const w = ratio > target ? tw : Math.round(th * ratio);
const h = ratio > target ? Math.round(tw / ratio) : th;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0, w, h);
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('canvas toBlob failed'))),
'image/jpeg',
0.8
);
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error('blob read failed'));
reader.readAsDataURL(blob);
});
}
// ── Lightbox ─────────────────────────────────────────────────────────────
let lbShowingOriginal = $state(false);
let lbFullResBusy = $state(false);
let lbMeta = $state('');
let lbFavorited = $state(false);
/** Token guarding against stale async loads during rapid prev/next. */
let lbGeneration = 0;
const lbItem = $derived(lightbox >= 0 ? (items[lightbox] ?? null) : null);
function baseMeta(p: FileItem): string {
const dateStr = new Date(ts(p)).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
return p.size_formatted ? `${dateStr} · ${p.size_formatted}` : dateStr;
}
function applyMetadata(p: FileItem, md: FileMetadata) {
const parts = [baseMeta(p)];
if (md.camera_make || md.camera_model) {
parts.push([md.camera_make, md.camera_model].filter(Boolean).join(' '));
}
if (md.width && md.height) parts.push(`${md.width}×${md.height}`);
lbMeta = parts.join(' · ');
}
function openLightbox(p: FileItem) {
lightbox = items.findIndex((x) => x.id === p.id);
}
/** Reset per-item lightbox state and kick off metadata + neighbour preload. */
function showLightboxItem(p: FileItem) {
const generation = ++lbGeneration;
lbShowingOriginal = p.mime_type === 'image/gif';
lbFullResBusy = false;
lbFavorited = false;
lbMeta = baseMeta(p);
preloadNeighbors();
void fetchFileMetadata(p.id).then((md) => {
if (md && generation === lbGeneration) applyMetadata(p, md);
});
}
// Re-run per-item setup whenever the visible lightbox item changes.
$effect(() => {
if (lbItem) showLightboxItem(lbItem);
});
function preloadNeighbors() {
for (const i of [lightbox - 1, lightbox + 1]) {
const it = items[i];
if (it && !isVideo(it)) {
const pre = new Image();
pre.src = largeUrl(it.id);
}
}
}
/** The image src to display: large thumbnail first, original on expand/GIF. */
const lbImgSrc = $derived(
lbItem ? (lbShowingOriginal ? fileInlineUrl(lbItem.id) : largeUrl(lbItem.id)) : ''
);
function onLbImgError() {
if (!lbItem) return;
// Thumbnail missing → fall back to the original; original failing is terminal.
if (!lbShowingOriginal) {
lbShowingOriginal = true;
}
}
function onLbImgLoad() {
lbFullResBusy = false;
}
function expandFullRes() {
if (!lbItem || lbShowingOriginal) return;
lbShowingOriginal = true;
lbFullResBusy = true;
}
function lbDownload() {
if (!lbItem) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(lbItem.id);
a.download = lbItem.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function lbToggleFavorite() {
if (!lbItem) return;
try {
await addFavorite('file', lbItem.id);
lbFavorited = !lbFavorited;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function lbDelete() {
if (!lbItem) return;
const target = lbItem;
const ok = await confirmDialog({
title: t('photos.delete', 'Delete photo'),
message: t('photos.confirm_delete_one', { name: target.name }, 'Delete {{name}}?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteFile(target.id);
const at = items.findIndex((x) => x.id === target.id);
items = items.filter((x) => x.id !== target.id);
if (items.length === 0) {
lightbox = -1;
} else {
lightbox = Math.min(at, items.length - 1);
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function lbPrev() {
if (lightbox > 0) lightbox -= 1;
}
function lbNext() {
if (lightbox >= 0 && lightbox < items.length - 1) lightbox += 1;
}
function onKeydown(e: KeyboardEvent) {
if (lightbox < 0) return;
if (e.key === 'Escape') lightbox = -1;
else if (e.key === 'ArrowLeft') lbPrev();
else if (e.key === 'ArrowRight') lbNext();
}
onMount(() => {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(GROUP_KEY) : null;
if (saved === 'day' || saved === 'month' || saved === 'year') groupMode = saved;
void loadMore();
if (!sentinel) return;
const obs = new IntersectionObserver(
@@ -41,62 +400,326 @@
obs.observe(sentinel);
return () => obs.disconnect();
});
const MODES: GroupMode[] = ['day', 'month', 'year'];
</script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<svelte:window onkeydown={onKeydown} />
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="page-sticky-header photos-head">
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
<div class="seg" role="group" aria-label={t('photos.group_by', 'Group by')}>
{#each MODES as m (m)}
<button class="seg__btn" class:active={groupMode === m} onclick={() => setGroupMode(m)}>
{t(`photos.${m}`, m)}
</button>
{/each}
</div>
</div>
{#if selected.size > 0}
<div class="batch-bar">
<span>{t('files.selected_count', { n: selected.size }, '{{n}} selected')}</span>
<div class="batch-bar__actions">
<button class="btn btn-secondary" onclick={downloadSelected}
>{t('common.download', 'Download')}</button
>
<button class="btn btn-secondary" onclick={() => (selected = new Set())}
>{t('common.clear', 'Clear')}</button
>
<button class="btn btn-danger" onclick={trashSelected}>{t('common.delete', 'Delete')}</button>
</div>
</div>
{/if}
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<p class="status">{t('photos.empty', 'No photos yet.')}</p>
<div class="empty-state">
<Icon name="images" class="empty-state__icon" />
<p class="empty-state__title">{t('photos.empty', 'No photos yet.')}</p>
<p class="empty-state__hint">
{t('photos.empty_hint', 'Photos and videos you upload will appear here, grouped by date.')}
</p>
</div>
{:else}
<ul class="photos">
{#each items as photo (photo.id)}
<li class="photos__cell">
<a href={fileInlineUrl(photo.id)} target="_blank" rel="noreferrer">
<img src={fileThumbnailUrl(photo.id)} alt={photo.name} loading="lazy" decoding="async" />
</a>
</li>
{/each}
</ul>
{#each groups as group (group.key)}
<h2 class="photos-group">
{group.label} <span class="photos-group__count">{group.photos.length}</span>
</h2>
<ul class="photos">
{#each group.photos as photo (photo.id)}
<li class="photos__cell" class:selected={selected.has(photo.id)}>
<button class="photos__open" onclick={() => onTileClick(photo)}>
{#if videoThumbs[photo.id]}
<img src={videoThumbs[photo.id]} alt={photo.name} loading="lazy" decoding="async" />
{:else}
<img
src={previewUrl(photo.id)}
srcset={`${iconUrl(photo.id)} 150w, ${previewUrl(photo.id)} 400w, ${largeUrl(photo.id)} 800w`}
sizes="(max-width: 768px) 33vw, 200px"
alt={photo.name}
loading="lazy"
decoding="async"
onerror={isVideo(photo) ? () => generateVideoThumb(photo) : undefined}
/>
{/if}
{#if isVideo(photo)}
<span class="photos__video-badge" aria-hidden="true"><Icon name="play" /></span>
{/if}
</button>
<button
class="photos__check"
class:on={selected.has(photo.id)}
aria-label={t('common.select', 'Select')}
onclick={() => toggle(photo.id)}
>
<Icon name="check" />
</button>
</li>
{/each}
</ul>
{/each}
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
{#if lbItem}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={lbItem.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
>
<div class="lb__info">
<div class="lb__filename">{lbItem.name}</div>
<div class="lb__meta">{lbMeta}</div>
</div>
<button
class="lb__close"
aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button
>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
e.stopPropagation();
lbPrev();
}}><Icon name="chevron-left" /></button
>
<div class="lb__content">
{#if isVideo(lbItem)}
{#key lbItem.id}
<video class="lb__media" controls autoplay poster={largeUrl(lbItem.id)}>
<source src={fileInlineUrl(lbItem.id)} type={lbItem.mime_type} />
</video>
{/key}
{:else}
<img
class="lb__media"
src={lbImgSrc}
alt={lbItem.name}
onload={onLbImgLoad}
onerror={onLbImgError}
/>
{/if}
</div>
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={lightbox === items.length - 1}
onclick={(e) => {
e.stopPropagation();
lbNext();
}}><Icon name="chevron-right" /></button
>
<div class="lb__toolbar">
{#if !isVideo(lbItem) && lbItem.mime_type !== 'image/gif' && !lbShowingOriginal}
<button
class="lb__tool"
title={t('photos.full_resolution', 'Full resolution')}
disabled={lbFullResBusy}
onclick={expandFullRes}><Icon name={lbFullResBusy ? 'spinner' : 'expand'} /></button
>
{/if}
<button class="lb__tool" title={t('common.download', 'Download')} onclick={lbDownload}
><Icon name="download" /></button
>
<button
class="lb__tool"
class:active={lbFavorited}
title={t('common.favorite', 'Favorite')}
onclick={lbToggleFavorite}><Icon name={lbFavorited ? 'star' : 'star-outline'} /></button
>
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={lbDelete}
><Icon name="trash" /></button
>
</div>
<div class="lb__counter">{lightbox + 1} / {items.length}</div>
</div>
{/if}
<style>
.photos-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: 1rem 1rem 0;
}
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
text-transform: capitalize;
}
.seg__btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin: var(--space-3) 1rem 0;
padding: var(--space-2) var(--space-3);
background: var(--color-accent-tint, var(--color-bg-hover));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.batch-bar__actions {
display: flex;
gap: var(--space-2);
}
.photos-group {
margin: var(--space-4) 0 var(--space-2);
padding: 0 1rem;
font-size: 1rem;
color: var(--color-text-heading);
}
.photos-group__count {
color: var(--color-text-muted);
font-size: var(--text-sm);
font-weight: var(--weight-normal);
}
.photos {
list-style: none;
margin: 0;
padding: 1rem;
padding: 0 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
.photos__cell {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photos__cell img {
.photos__cell.selected {
outline: 3px solid var(--color-accent);
outline-offset: -3px;
}
.photos__open {
display: block;
width: 100%;
height: 100%;
border: none;
padding: 0;
cursor: pointer;
background: none;
}
.photos__open img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.photos__video-badge {
position: absolute;
right: 6px;
bottom: 6px;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--color-scrim-control);
color: var(--color-on-accent);
display: grid;
place-items: center;
font-size: 0.7rem;
pointer-events: none;
}
.photos__check {
position: absolute;
top: 6px;
left: 6px;
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--color-on-accent);
background: var(--color-scrim-control);
color: transparent;
display: grid;
place-items: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.photos__cell:hover .photos__check,
.photos__check.on {
opacity: 1;
}
.photos__check.on {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
.status {
text-align: center;
color: var(--color-text-muted);
@@ -107,7 +730,153 @@
color: var(--color-danger-text);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
text-align: center;
padding: 4rem 1rem;
color: var(--color-text-muted);
}
.empty-state :global(.empty-state__icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
.empty-state__title {
margin: 0;
font-size: 1.1rem;
color: var(--color-text-heading);
}
.empty-state__hint {
margin: 0;
max-width: 28rem;
}
.sentinel {
height: 1px;
}
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__content {
max-width: 92vw;
max-height: 88vh;
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__info {
position: absolute;
top: 1rem;
left: 1rem;
color: var(--color-on-accent);
max-width: 60vw;
}
.lb__filename {
font-weight: var(--weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lb__meta {
font-size: var(--text-sm);
opacity: 0.8;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
.lb__toolbar {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-2);
}
.lb__tool {
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: var(--color-scrim-control);
color: var(--color-on-accent);
cursor: pointer;
display: grid;
place-items: center;
}
.lb__tool:disabled {
opacity: 0.5;
cursor: default;
}
.lb__tool.active {
color: var(--color-accent);
}
.lb__counter {
position: absolute;
bottom: 1rem;
right: 1rem;
color: var(--color-on-accent);
font-size: var(--text-sm);
opacity: 0.8;
}
</style>
File diff suppressed because it is too large Load Diff
+340 -44
View File
@@ -1,85 +1,381 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import {
addFavorite,
dateBucket,
fetchFavoritesPage,
removeFavorite,
resolveOwnerName,
sizeBucket,
typeLabel
} from '$lib/api/endpoints/favorites';
import { fileDownloadUrl, renameFile, deleteFile } from '$lib/api/endpoints/files';
import { renameFolder, deleteFolder } from '$lib/api/endpoints/folders';
import type { FileItem, ItemType } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import FileViewer from '$lib/components/FileViewer.svelte';
import MoveDialog from '$lib/components/MoveDialog.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import ResourceList, {
type ContextAction,
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<RecentResourceItem[]>([]);
let raw = $state<RecentResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state('');
let reversed = $state(false);
let ownerNames = $state<Record<string, string>>({});
let favoriteIds = $state<Set<string>>(new Set());
async function load(reset = false) {
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
const ownerId = it.resource.owner_id ?? null;
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
date: it.accessed_at,
ownerId,
ownerName: ownerId ? (ownerNames[ownerId] ?? null) : null,
isFavorite: favoriteIds.has(it.resource.id),
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.resource.modified_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'owner',
label: t('groupby.owner', 'Owner'),
orderBy: 'owner',
bucketOf: (e) => e.ownerId ?? null,
labelOf: (id) => ownerNames[id] ?? id
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'accessedAt',
label: t('groupby.accessedAt', 'Accessed date'),
orderBy: 'accessed_at',
bucketOf: (e) => dateBucket(e.date)
},
{
key: 'modifiedAt',
label: t('groupby.modifiedAt', 'Modified date'),
orderBy: 'modified_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
async function resolveOwners(items: RecentResourceItem[]) {
const ids = [
...new Set(items.map((i) => i.resource.owner_id).filter((id): id is string => !!id))
];
await Promise.all(
ids.map(async (id) => {
if (ownerNames[id]) return;
const name = await resolveOwnerName(id);
ownerNames = { ...ownerNames, [id]: name };
})
);
}
async function loadFavoriteIds() {
try {
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
favoriteIds = new Set(favs.items.map((f) => f.resource.id));
} catch {
// non-fatal — stars just default to off
}
}
// Recent defaults to most-recently-accessed first (accessed_at DESC).
async function load(reset = false, orderBy = 'accessed_at', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchRecentPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchRecentPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
void resolveOwners(page.items);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('recent: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'accessed_at';
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
async function toggleFavorite(entry: ResourceEntry) {
const isFav = favoriteIds.has(entry.id);
const next = new Set(favoriteIds);
if (isFav) next.delete(entry.id);
else next.add(entry.id);
favoriteIds = next;
try {
if (isFav) await removeFavorite(entry.kind, entry.id);
else await addFavorite(entry.kind, entry.id);
} catch (e) {
// revert on failure
favoriteIds = isFav
? new Set([...favoriteIds, entry.id])
: new Set([...favoriteIds].filter((id) => id !== entry.id));
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function clearAll() {
const ok = await confirmDialog({
title: t('recent.clear', 'Clear recent'),
message: t('recent.confirm_clear', 'Clear your recent items?'),
confirmText: t('recent.clear', 'Clear recent')
});
if (!ok) return;
try {
await clearRecent();
items = [];
raw = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
// ── Context-menu actions ──────────────────────────────────────────────────
let moveOpen = $state(false);
let moveTarget = $state<{ id: string; name: string; kind: ItemType } | null>(null);
let moveItems = $state<{ id: string; name: string; kind: ItemType }[] | null>(null);
let shareOpen = $state(false);
let shareTarget = $state<{ id: string; name: string; kind: ItemType } | null>(null);
async function rename(entry: ResourceEntry) {
const name = await promptDialog({
title: t('common.rename', 'Rename'),
defaultValue: entry.name,
confirmText: t('common.rename', 'Rename')
});
if (!name || name === entry.name) return;
try {
if (entry.kind === 'file') await renameFile(entry.id, name);
else await renameFolder(entry.id, name);
await load(true, orderByForGroup());
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function remove(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t('files.confirm_delete', { name: entry.name }, 'Delete "{{name}}"?'),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
if (entry.kind === 'file') await deleteFile(entry.id);
else await deleteFolder(entry.id);
raw = raw.filter((i) => i.resource.id !== entry.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function downloadEntry(entry: ResourceEntry) {
if (entry.kind !== 'file') return;
const a = document.createElement('a');
a.href = fileDownloadUrl(entry.id);
a.download = entry.name;
document.body.appendChild(a);
a.click();
a.remove();
}
const contextActions: ContextAction[] = [
{
key: 'download',
label: t('common.download', 'Download'),
icon: 'download',
run: downloadEntry
},
{
key: 'share',
label: t('files.share', 'Share'),
icon: 'share-alt',
run: (e) => {
shareTarget = { id: e.id, name: e.name, kind: e.kind };
shareOpen = true;
}
},
{
key: 'move',
label: t('files.move', 'Move'),
icon: 'arrows-alt',
run: (e) => {
moveItems = null;
moveTarget = { id: e.id, name: e.name, kind: e.kind };
moveOpen = true;
}
},
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
];
// ── Selection + batch ─────────────────────────────────────────────────────
let selectedIds = $state<Set<string>>(new Set());
const selectedEntries = $derived(entries.filter((e) => selectedIds.has(e.id)));
function batchTargets() {
return selectedEntries.map((e) => ({ id: e.id, name: e.name, kind: e.kind }));
}
function batchDownload() {
for (const e of selectedEntries) downloadEntry(e);
}
async function batchDelete() {
const ok = await confirmDialog({
title: t('common.delete', 'Delete'),
message: t(
'files.confirm_delete_n',
{ count: selectedEntries.length },
'Delete {{count}} item(s)?'
),
confirmText: t('common.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await Promise.all(
selectedEntries.map((e) => (e.kind === 'file' ? deleteFile(e.id) : deleteFolder(e.id)))
);
const removed = new Set(selectedEntries.map((e) => e.id));
raw = raw.filter((i) => !removed.has(i.resource.id));
selectedIds = new Set();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => {
void loadFavoriteIds();
void load(true);
});
</script>
<svelte:head><title>{t('nav.recent', 'Recent')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.recent', 'Recent')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.recent', 'Recent')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('recent.empty', 'No recent items.')}
emptyIcon="clock"
emptyText={t('recent.empty_state', 'No recent files')}
emptyHint={t('recent.empty_hint', 'Files you open will appear here')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
onopen={open}
onfavorite={toggleFavorite}
showOwner
selectable
{contextActions}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
onselectionchange={(ids) => (selectedIds = ids)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn" onclick={clearAll}>{t('recent.clear', 'Clear')}</button>
{#if entries.length > 0}
<button class="btn btn-secondary" onclick={clearAll}>
<Icon name="broom" />
{t('recent.clear', 'Clear recent')}
</button>
{/if}
{/snippet}
{#snippet batchToolbar()}
<button class="btn btn-secondary" onclick={batchDownload}>
<Icon name="download" />
{t('common.download', 'Download')}
</button>
<button
class="btn btn-secondary"
onclick={() => {
moveTarget = null;
moveItems = batchTargets();
moveOpen = true;
}}><Icon name="arrows-alt" /> {t('files.move', 'Move')}</button
>
<button class="btn btn-danger" onclick={batchDelete}>
<Icon name="trash" />
{t('common.delete', 'Delete')}
</button>
{/snippet}
</ResourceList>
{#each items as item (item.resource.id + item.accessed_at)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.accessed_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
<MoveDialog
bind:open={moveOpen}
item={moveTarget}
items={moveItems}
onmoved={() => {
selectedIds = new Set();
load(true, orderByForGroup());
}}
/>
<ShareDialog bind:open={shareOpen} item={shareTarget} />
+433 -25
View File
@@ -14,38 +14,87 @@
} from '$lib/api/endpoints/share';
import { t } from '$lib/i18n/index.svelte';
type State = 'loading' | 'password' | 'expired' | 'file' | 'folder';
type State = 'loading' | 'password' | 'expired' | 'invalid' | 'file' | 'folder';
type Crumb = { id?: string; name: string };
type ViewMode = 'grid' | 'list';
const VIEW_KEY = 'oxicloud_share_view';
const token = $derived(page.params.token ?? '');
let view = $state<State>('loading');
let meta = $state<ShareMeta | null>(null);
let listing = $state<ShareListing | null>(null);
let folderId = $state<string | undefined>(undefined);
let folderName = $state<string>('');
let crumbs = $state<Crumb[]>([]);
let pwInput = $state('');
let pwError = $state('');
let busy = $state(false);
let viewMode = $state<ViewMode>('grid');
// Lightbox over the media files in the current folder
let lightbox = $state(-1);
function mediaKind(mime: string | undefined): 'image' | 'video' | null {
const m = (mime ?? '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
return null;
}
const mediaFiles = $derived(
(listing?.files ?? []).filter((f) => mediaKind(f.mime_type) !== null)
);
function setViewMode(mode: ViewMode) {
viewMode = mode;
try {
localStorage.setItem(VIEW_KEY, mode);
} catch {
/* storage unavailable — keep in-memory only */
}
}
async function loadMeta() {
view = 'loading';
// Guard a missing/blank token before hitting the API.
if (!token) {
view = 'invalid';
return;
}
try {
const r = await getShareMeta(token);
if (r.status === 'password') {
view = 'password';
} else if (r.status === 'expired') {
view = 'expired';
} else if (r.status === 'invalid') {
view = 'invalid';
} else {
meta = r.data;
if (r.data.item_type === 'folder') await openFolder(undefined, r.data.item_name);
else view = 'file';
if (r.data.item_type === 'folder') {
crumbs = [{ name: r.data.item_name }];
// Deep-link support: honour an initial #folder=<id> hash.
await openFolder(hashFolderId(), undefined, false);
} else view = 'file';
}
} catch {
view = 'expired';
}
}
async function openFolder(id: string | undefined, name: string) {
/** Parse the `#folder=<id>` fragment from the URL, if present. */
function hashFolderId(): string | undefined {
if (typeof location === 'undefined') return undefined;
const m = location.hash.match(/[#&]folder=([A-Za-z0-9-]{1,64})/);
return m ? m[1] : undefined;
}
/**
* Load a folder's contents. When `crumb` is given, push it onto the trail.
* `pushHistory` controls whether we sync the URL hash + push a history entry
* (true for user navigation, false when restoring from popstate / deep link).
*/
async function openFolder(id: string | undefined, crumb?: Crumb, pushHistory = false) {
const r = await getShareContents(token, id);
if (r.status === 'password') {
view = 'password';
@@ -57,8 +106,109 @@
}
listing = r.data;
folderId = id;
folderName = name;
if (crumb) crumbs = [...crumbs, crumb];
lightbox = -1;
view = 'folder';
if (pushHistory && typeof history !== 'undefined') {
const hash = id ? `#folder=${encodeURIComponent(id)}` : '';
history.pushState({ folderId: id }, '', location.pathname + location.search + hash);
}
}
/** Navigate to a breadcrumb at depth `index` (0 = share root). */
async function gotoCrumb(index: number) {
const target = crumbs[index];
crumbs = crumbs.slice(0, index + 1);
await openFolder(target.id, undefined, true);
}
/** Browser back/forward — re-resolve the folder from the popped state/hash. */
async function onPopState() {
if (view !== 'folder') return;
await openFolder(hashFolderId(), undefined, false);
}
/** Append a cache-busting query param to retry a failed media load once. */
function retrySrc(original: string): string {
const sep = original.indexOf('?') === -1 ? '?' : '&';
return `${original}${sep}_r=${Date.now()}`;
}
/**
* Lazy video poster: defer loading until near the viewport, then seek a few
* frames in to render a thumbnail. Retries once with cache-busting on error.
* Ported from publicShare.js wireLazyVideos().
*/
function lazyVideo(node: HTMLVideoElement, src: string) {
let retried = false;
const start = () => {
node.addEventListener(
'loadedmetadata',
() => {
const at = Math.min(0.1, (node.duration || 1) * 0.1);
try {
node.currentTime = at;
} catch {
/* seeking unsupported */
}
},
{ once: true }
);
node.addEventListener(
'error',
() => {
if (retried) return;
retried = true;
setTimeout(() => (node.src = retrySrc(src)), 250);
},
{ once: true }
);
node.src = src;
};
let obs: IntersectionObserver | null = null;
if (typeof IntersectionObserver !== 'undefined') {
obs = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
start();
obs?.unobserve(node);
}
}
},
{ rootMargin: '300px' }
);
obs.observe(node);
} else {
start();
}
return { destroy: () => obs?.disconnect() };
}
/** Retry a failed image load once with cache-busting. Ported from wireImageRetry(). */
function imageRetry(node: HTMLImageElement) {
let retried = false;
const onError = () => {
if (retried) return;
retried = true;
const original = node.src;
setTimeout(() => (node.src = retrySrc(original)), 250);
};
node.addEventListener('error', onError);
return { destroy: () => node.removeEventListener('error', onError) };
}
function lbPrev() {
if (lightbox > 0) lightbox -= 1;
}
function lbNext() {
if (lightbox >= 0 && lightbox < mediaFiles.length - 1) lightbox += 1;
}
function onKeydown(e: KeyboardEvent) {
if (lightbox < 0) return;
if (e.key === 'Escape') lightbox = -1;
else if (e.key === 'ArrowLeft') lbPrev();
else if (e.key === 'ArrowRight') lbNext();
}
async function submitPassword(e: SubmitEvent) {
@@ -80,14 +230,28 @@
}
}
onMount(loadMeta);
onMount(() => {
try {
const saved = localStorage.getItem(VIEW_KEY);
if (saved === 'list' || saved === 'grid') viewMode = saved;
} catch {
/* ignore */
}
void loadMeta();
});
</script>
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
<svelte:window onkeydown={onKeydown} onpopstate={onPopState} />
<main class="share">
{#if view === 'loading'}
<p class="share__status">{t('common.loading', 'Loading…')}</p>
{:else if view === 'invalid'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
<p>{t('share.invalid', 'This share link is invalid.')}</p>
</div>
{:else if view === 'expired'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
@@ -116,14 +280,35 @@
</div>
{:else if view === 'folder' && listing}
<header class="share__header">
<h1>{folderName}</h1>
<nav class="breadcrumb" aria-label={t('files.breadcrumb', 'Breadcrumb')}>
{#each crumbs as c, i (i)}
{#if i > 0}<Icon name="chevron-right" class="breadcrumb__sep" />{/if}
{#if i === crumbs.length - 1}
<span class="breadcrumb__current">{c.name}</span>
{:else}
<button class="breadcrumb__link" onclick={() => gotoCrumb(i)}>{c.name}</button>
{/if}
{/each}
</nav>
<div class="share__header-actions">
{#if folderId}
<button class="link-btn" onclick={() => openFolder(undefined, meta?.item_name ?? '')}>
← {t('share.back_to_root', 'Back to share root')}
</button>
{/if}
<div class="view-toggle" role="group" aria-label={t('files.view', 'View')}>
<button
type="button"
aria-pressed={viewMode === 'grid'}
class:active={viewMode === 'grid'}
title={t('files.grid', 'Grid')}
onclick={() => setViewMode('grid')}><Icon name="th" /></button
>
<button
type="button"
aria-pressed={viewMode === 'list'}
class:active={viewMode === 'list'}
title={t('files.list', 'List')}
onclick={() => setViewMode('list')}><Icon name="bars" /></button
>
</div>
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
<Icon name="file-archive" />
{t('share.download_zip', 'Download ZIP')}
</a>
</div>
@@ -135,11 +320,11 @@
{#if listing.folders.length > 0}
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
<ul class="share__grid">
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.folders as f (f.id)}
<li>
<button class="card" onclick={() => openFolder(f.id, f.name)}>
<Icon name="folder" class="card__icon" />
<button class="card" onclick={() => openFolder(f.id, { id: f.id, name: f.name }, true)}>
<span class="card__thumb"><Icon name="folder" class="card__icon" /></span>
<span class="card__name">{f.name}</span>
</button>
</li>
@@ -149,20 +334,94 @@
{#if listing.files.length > 0}
<h2 class="share__section">{t('share.files', 'Files')}</h2>
<ul class="share__grid">
<ul class="share__grid" class:share__grid--list={viewMode === 'list'}>
{#each listing.files as f (f.id)}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<Icon name="file" class="card__icon" />
<span class="card__name">{f.name}</span>
</a>
</li>
{@const kind = mediaKind(f.mime_type)}
{#if kind}
<li>
<button
class="card"
onclick={() => (lightbox = mediaFiles.findIndex((m) => m.id === f.id))}
>
<span class="card__thumb">
{#if kind === 'image'}
<img
src={shareFileUrl(token, f.id)}
alt={f.name}
loading="lazy"
decoding="async"
use:imageRetry
/>
{:else}
<video
use:lazyVideo={shareFileUrl(token, f.id)}
preload="metadata"
muted
playsinline
></video>
<span class="card__play"><Icon name="play" /></span>
{/if}
</span>
<span class="card__name">{f.name}</span>
</button>
</li>
{:else}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<span class="card__thumb"><Icon name="file" class="card__icon" /></span>
<span class="card__name">{f.name}</span>
</a>
</li>
{/if}
{/each}
</ul>
{/if}
{/if}
</main>
{#if lightbox >= 0 && mediaFiles[lightbox]}
{@const m = mediaFiles[lightbox]}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={m.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && (lightbox = -1)}
>
<button
class="lb__close"
aria-label={t('common.close', 'Close')}
onclick={() => (lightbox = -1)}>×</button
>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={lightbox === 0}
onclick={(e) => {
e.stopPropagation();
lbPrev();
}}><Icon name="chevron-left" /></button
>
{#if mediaKind(m.mime_type) === 'image'}
<img class="lb__media" src={shareFileUrl(token, m.id)} alt={m.name} />
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<video class="lb__media" src={shareFileUrl(token, m.id)} controls autoplay></video>
{/if}
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={lightbox === mediaFiles.length - 1}
onclick={(e) => {
e.stopPropagation();
lbNext();
}}><Icon name="chevron-right" /></button
>
</div>
{/if}
<style>
.share {
max-width: 60rem;
@@ -248,7 +507,7 @@
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 0.5rem;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
@@ -261,6 +520,35 @@
background: var(--color-bg-hover);
}
.card__thumb {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.card__thumb img,
.card__thumb video {
width: 100%;
height: 100%;
object-fit: cover;
}
.card__play {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-on-accent);
font-size: 1.5rem;
background: var(--color-scrim-control, transparent);
opacity: 0.85;
}
:global(.card__icon) {
font-size: 2rem;
color: var(--color-text-muted);
@@ -275,7 +563,36 @@
max-width: 100%;
}
/* List mode: cards become single-row rows with a small leading thumb. */
.share__grid--list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.share__grid--list .card {
flex-direction: row;
align-items: center;
gap: 0.75rem;
padding: 0.4rem 0.6rem;
}
.share__grid--list .card__thumb {
width: 2.5rem;
height: 2.5rem;
aspect-ratio: auto;
flex-shrink: 0;
}
.share__grid--list .card__name {
text-align: left;
flex: 1;
}
.share__btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
@@ -285,10 +602,101 @@
cursor: pointer;
}
.link-btn {
.breadcrumb {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.breadcrumb__link {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 1rem;
padding: 0;
}
.breadcrumb__current {
font-weight: var(--weight-semibold, 600);
}
:global(.breadcrumb__sep) {
color: var(--color-text-muted);
font-size: 0.75rem;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-toggle button {
padding: 0.4rem 0.6rem;
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.view-toggle button.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.lb {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--color-lightbox-overlay);
display: flex;
align-items: center;
justify-content: center;
}
.lb__media {
max-width: 92vw;
max-height: 88vh;
object-fit: contain;
}
.lb__close {
position: absolute;
top: 1rem;
right: 1rem;
font-size: 2rem;
line-height: 1;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
}
.lb__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
font-size: 2rem;
background: none;
border: none;
color: var(--color-on-accent);
cursor: pointer;
padding: 1rem;
}
.lb__nav:disabled {
opacity: 0.3;
cursor: default;
}
.lb__nav--prev {
left: 0.5rem;
}
.lb__nav--next {
right: 0.5rem;
}
</style>
+398
View File
@@ -0,0 +1,398 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { searchFiles } from '$lib/api/endpoints/search';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem, SearchResults, SortBy } from '$lib/api/types';
import Icon from '$lib/icons/Icon.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';
const query = $derived(page.url.searchParams.get('q') ?? '');
let results = $state<SearchResults | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let sortBy = $state<SortBy>('relevance');
// Scope: search everywhere, or within the folder last open in the files view.
// Default to the current folder when one is set (and we're not in the trash
// section), mirroring the legacy searchView behaviour.
let scope = $state<'all' | 'folder'>(
filesStore.currentFolder && filesStore.section !== 'trash' ? 'folder' : 'all'
);
// Filters
type TypeKey = 'all' | 'image' | 'video' | 'document' | 'audio' | 'archive';
type SizeKey = 'all' | 'small' | 'medium' | 'large';
type DateKey = 'all' | 'day' | 'week' | 'month' | 'year';
let typeFilter = $state<TypeKey>('all');
let sizeFilter = $state<SizeKey>('all');
let dateFilter = $state<DateKey>('all');
const TYPE_EXT: Record<Exclude<TypeKey, 'all'>, string[]> = {
image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
video: ['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'wmv', 'flv'],
document: [
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'odt',
'rtf',
'csv'
],
audio: ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'opus'],
archive: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz']
};
const TYPES: { v: TypeKey; l: string }[] = [
{ v: 'all', l: t('search.type.all', 'All types') },
{ v: 'image', l: t('search.type.image', 'Images') },
{ v: 'video', l: t('search.type.video', 'Videos') },
{ v: 'document', l: t('search.type.document', 'Documents') },
{ v: 'audio', l: t('search.type.audio', 'Audio') },
{ v: 'archive', l: t('search.type.archive', 'Archives') }
];
const SIZES: { v: SizeKey; l: string }[] = [
{ v: 'all', l: t('search.size.all', 'Any size') },
{ v: 'small', l: t('search.size.small', '< 1 MB') },
{ v: 'medium', l: t('search.size.medium', '1–100 MB') },
{ v: 'large', l: t('search.size.large', '> 100 MB') }
];
const DATES: { v: DateKey; l: string }[] = [
{ v: 'all', l: t('search.date.all', 'Any time') },
{ v: 'day', l: t('search.date.day', 'Past 24 hours') },
{ v: 'week', l: t('search.date.week', 'Past week') },
{ v: 'month', l: t('search.date.month', 'Past month') },
{ v: 'year', l: t('search.date.year', 'Past year') }
];
const MB = 1024 * 1024;
function sizeBounds(k: SizeKey): { minSize?: number; maxSize?: number } {
switch (k) {
case 'small':
return { maxSize: MB };
case 'medium':
return { minSize: MB, maxSize: 100 * MB };
case 'large':
return { minSize: 100 * MB };
default:
return {};
}
}
function dateBound(k: DateKey): number | undefined {
const day = 86400;
const now = Math.floor(Date.now() / 1000);
switch (k) {
case 'day':
return now - day;
case 'week':
return now - 7 * day;
case 'month':
return now - 30 * day;
case 'year':
return now - 365 * day;
default:
return undefined;
}
}
const hasFilters = $derived(typeFilter !== 'all' || sizeFilter !== 'all' || dateFilter !== 'all');
function clearFilters() {
typeFilter = 'all';
sizeFilter = 'all';
dateFilter = 'all';
}
const SORTS: { v: SortBy; l: string }[] = [
{ v: 'relevance', l: t('search.sort.relevance', 'Relevance') },
{ v: 'name', l: t('search.sort.name_asc', 'Name A-Z') },
{ v: 'name_desc', l: t('search.sort.name_desc', 'Name Z-A') },
{ v: 'date_desc', l: t('search.sort.newest', 'Newest') },
{ v: 'date', l: t('search.sort.oldest', 'Oldest') },
{ v: 'size_desc', l: t('search.sort.largest', 'Largest') },
{ v: 'size', l: t('search.sort.smallest', 'Smallest') }
];
async function run(q: string) {
if (!q) {
results = null;
return;
}
loading = true;
error = null;
try {
// Trash section searches are always global — there is no folder to scope to.
const folderId =
scope === 'folder' && filesStore.section !== 'trash'
? (filesStore.currentFolder ?? undefined)
: undefined;
results = await searchFiles(q, {
recursive: true,
sortBy,
folderId,
fileTypes: typeFilter === 'all' ? undefined : TYPE_EXT[typeFilter],
...sizeBounds(sizeFilter),
modifiedAfter: dateBound(dateFilter)
});
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function openFolder(folder: FolderItem) {
goto(`/files/${folder.id}`);
}
function openFile(file: FileItem) {
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
}
const isEmpty = $derived(!!results && results.files.length === 0 && results.folders.length === 0);
$effect(() => {
// re-run when query, sort, scope, or any filter changes
void sortBy;
void scope;
void typeFilter;
void sizeFilter;
void dateFilter;
void run(query);
});
</script>
<svelte:head><title>{t('search.title', 'Search')} · OxiCloud</title></svelte:head>
<div class="page-sticky-header search-head">
<h1 class="page-title">
{#if query}{t('search.results_for', { q: query }, 'Results for “{{q}}”')}{:else}{t(
'search.title',
'Search'
)}{/if}
{#if results?.query_time_ms != null}
<span class="search-time">({results.query_time_ms} ms)</span>
{/if}
</h1>
{#if query}
<div class="search-controls">
{#if filesStore.currentFolder}
<div class="seg" role="group" aria-label={t('search.scope', 'Scope')}>
<button class="seg__btn" class:active={scope === 'all'} onclick={() => (scope = 'all')}>
{t('search.everywhere', 'Everywhere')}
</button>
<button
class="seg__btn"
class:active={scope === 'folder'}
onclick={() => (scope = 'folder')}
>
{t('search.this_folder', 'This folder')}
</button>
</div>
{/if}
<select
class="sort-select"
bind:value={typeFilter}
aria-label={t('search.type_label', 'Type')}
>
{#each TYPES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select
class="sort-select"
bind:value={sizeFilter}
aria-label={t('search.size_label', 'Size')}
>
{#each SIZES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select
class="sort-select"
bind:value={dateFilter}
aria-label={t('search.date_label', 'Date')}
>
{#each DATES as o (o.v)}<option value={o.v}>{o.l}</option>{/each}
</select>
<select class="sort-select" bind:value={sortBy} aria-label={t('search.sort_by', 'Sort by')}>
{#each SORTS as s (s.v)}<option value={s.v}>{s.l}</option>{/each}
</select>
{#if hasFilters}
<button class="clear-filters" onclick={clearFilters}>
<Icon name="times" />
{t('search.clear_filters', 'Clear filters')}
</button>
{/if}
</div>
{/if}
</div>
{#if loading}
<div class="search-loading">
<Icon name="spinner" class="search-loading__spinner" />
<h2 class="search-loading__text">
{t('search.searching_for', { q: query }, 'Searching for “{{q}}”…')}
</h2>
</div>
{:else if error}
<div class="empty-state"><p>{error}</p></div>
{:else if !query}
<div class="empty-state">
<p>{t('search.prompt', 'Type a query in the search bar above.')}</p>
</div>
{:else if isEmpty}
<div class="empty-state search-empty">
<Icon name="search" />
<p>{t('search.no_results', 'No results found for this search')}</p>
</div>
{:else if results}
<div class="files-container">
<div class="files-list-view" style="--files-list-columns: minmax(200px, 2fr) 1fr 110px 140px">
<div class="list-header">
<div>{t('files.col_name', 'Name')}</div>
<div>{t('files.col_path', 'Path')}</div>
<div>{t('files.col_size', 'Size')}</div>
<div>{t('files.col_modified', 'Modified')}</div>
</div>
{#each results.folders as folder (folder.id)}
<div
class="file-item"
role="button"
tabindex="0"
onclick={() => openFolder(folder)}
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
>
<div class="name-cell">
<span class="file-icon"><Icon name="folder" /></span>
<span>{folder.name}</span>
</div>
<div class="path-cell">{folder.path}</div>
<div class="size-cell">—</div>
<div class="date-cell">{formatDate(folder.modified_at)}</div>
</div>
{/each}
{#each results.files as file (file.id)}
<div
class="file-item"
role="button"
tabindex="0"
onclick={() => openFile(file)}
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
>
<div class="name-cell">
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
<span>{file.name}</span>
</div>
<div class="path-cell">{file.path}</div>
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
<div class="date-cell">{formatDate(file.modified_at)}</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.search-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
flex-wrap: wrap;
}
.search-controls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sort-select {
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.seg__btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.search-time {
font-size: var(--text-sm);
font-weight: var(--weight-normal);
color: var(--color-text-muted);
}
.clear-filters {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.clear-filters:hover {
background: var(--color-bg-hover);
}
.search-loading {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4) 0;
color: var(--color-text-muted);
}
.search-loading :global(.search-loading__spinner) {
font-size: var(--text-xl);
color: var(--color-accent);
animation: spin var(--spin-duration) linear infinite;
}
.search-loading__text {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-medium);
color: var(--color-text);
}
.search-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
.search-empty :global(.oxi-icon) {
font-size: var(--text-3xl);
color: var(--color-text-faint);
}
</style>
+45 -29
View File
@@ -1,22 +1,41 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import type { FileItem } from '$lib/api/types';
import FileViewer from '$lib/components/FileViewer.svelte';
import ResourceList, { type ResourceEntry } from '$lib/components/ResourceList.svelte';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<IncomingGrantItem[]>([]);
let raw = $state<IncomingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
const byId = $derived(new Map(raw.map((it) => [it.resource.id, it])));
const entries = $derived(
raw.map(
(it): ResourceEntry => ({
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.granted_by
? t('shared_with_me.from', { who: it.granted_by }, 'Shared by {{who}}')
: it.resource.path,
size: it.resource_type === 'file' ? (it.resource as FileItem).size : null,
date: it.granted_at
})
)
);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchSharedWithMe({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -25,38 +44,35 @@
}
}
let viewerOpen = $state(false);
let viewerFile = $state<FileItem | null>(null);
function open(entry: ResourceEntry) {
if (entry.kind === 'folder') {
goto(`/files/${entry.id}`);
return;
}
const item = byId.get(entry.id);
if (item) {
viewerFile = item.resource as FileItem;
viewerOpen = true;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared_with_me', 'Shared with me')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared_with_me', 'Shared with me')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.shared_with_me', 'Shared with me')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.granted_by
? t('shared_with_me.from', { who: item.granted_by }, 'Shared by {{who}}')
: item.resource.path}
date={formatDate(item.granted_at)}
/>
{/each}
</ResourceListShell>
onopen={open}
/>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
<FileViewer bind:open={viewerOpen} file={viewerFile} />
+819 -31
View File
@@ -1,22 +1,155 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchMyShares, type OutgoingGrantItem } from '$lib/api/endpoints/grants';
import {
expiryToIso,
fetchMyShares,
notifyGrantRecipient,
revokeGrant,
updateGrantRole,
type NotifyOutcome,
type OutgoingGrantItem,
type OutgoingResourceGrant,
type ShareRole
} from '$lib/api/endpoints/grants';
import { copyShareLink, deleteShare, getShareById, updateShare } from '$lib/api/endpoints/shares';
import { ensureResolvers, resolveLabel } from '$lib/api/endpoints/recipients';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import ListToolbar from '$lib/components/ListToolbar.svelte';
import ShareDialog from '$lib/components/ShareDialog.svelte';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
import { ui } from '$lib/stores/ui.svelte';
import { iconNameFromClass } from '$lib/utils/display';
let items = $state<OutgoingGrantItem[]>([]);
type GroupBy = 'items' | 'sharedWith';
const GROUP_BYS: { key: GroupBy; label: string; orderBy: string }[] = [
{ key: 'items', label: t('groupby.byFiles', 'By files'), orderBy: 'type' },
{ key: 'sharedWith', label: t('groupby.sharedWith', 'Shared with'), orderBy: 'subject' }
];
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' }
];
function roleMeta(r: string) {
return ROLES.find((x) => x.v === r) ?? ROLES[2];
}
let raw = $state<OutgoingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
let groupBy = $state<GroupBy>('items');
let reversed = $state(false);
// Edit-sharing dialog
let dialogOpen = $state(false);
let dialogItem = $state<{ id: string; name: string; kind: ItemType } | null>(null);
// Open kebab menu, keyed by grant id.
let menuFor = $state<string | null>(null);
function expiryTier(iso: string | null | undefined): 'never' | 'active' | 'soon' | 'expired' {
if (!iso) return 'never';
const ms = new Date(iso).getTime() - Date.now();
if (ms < 0) return 'expired';
if (ms <= 30 * 86_400_000) return 'soon';
return 'active';
}
function expiryLabel(iso: string | null | undefined): string {
if (!iso) return t('share.noExpiry', 'No expiry');
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function isoToDate(iso: string | null | undefined): string {
return iso ? String(iso).slice(0, 10) : '';
}
// ── Swimlane assembly ───────────────────────────────────────────────────
interface Lane {
key: string;
header:
| { kind: 'resource'; item: OutgoingGrantItem }
| { kind: 'user'; id: string }
| { kind: 'group'; id: string }
| { kind: 'linkPublic' }
| { kind: 'linkPassword' };
rows: { grant: OutgoingResourceGrant; item: OutgoingGrantItem }[];
}
const lanes = $derived.by((): Lane[] => {
const out: Lane[] = [];
const byKey = new Map<string, Lane>();
const ensure = (key: string, header: Lane['header']): Lane => {
let lane = byKey.get(key);
if (!lane) {
lane = { key, header, rows: [] };
byKey.set(key, lane);
out.push(lane);
}
return lane;
};
for (const item of raw) {
if (groupBy === 'items') {
const lane = ensure(`resource:${item.resource.id}`, { kind: 'resource', item });
for (const grant of item.grants) lane.rows.push({ grant, item });
} else {
for (const grant of item.grants) {
let key: string;
let header: Lane['header'];
if (grant.subject_type === 'user') {
key = `user:${grant.subject_id}`;
header = { kind: 'user', id: grant.subject_id };
} else if (grant.subject_type === 'group') {
key = `group:${grant.subject_id}`;
header = { kind: 'group', id: grant.subject_id };
} else if (grant.has_password) {
key = 'links:password';
header = { kind: 'linkPassword' };
} else {
key = 'links:public';
header = { kind: 'linkPublic' };
}
ensure(key, header).rows.push({ grant, item });
}
}
}
return out;
});
function laneTitle(header: Lane['header']): string {
switch (header.kind) {
case 'user':
return resolveLabel('user', header.id);
case 'group':
return resolveLabel('group', header.id);
case 'linkPublic':
return t('myshares.publicLinks', 'Public links');
case 'linkPassword':
return t('myshares.passwordLinks', 'Password-protected links');
case 'resource':
return header.item.resource.name;
}
}
// ── Data loading ────────────────────────────────────────────────────────
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchMyShares({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
await ensureResolvers();
const order = GROUP_BYS.find((g) => g.key === groupBy)?.orderBy;
const page = await fetchMyShares({
cursor: reset ? undefined : cursor,
orderBy: order,
reverse: reversed
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
@@ -25,38 +158,693 @@
}
}
function reload() {
cursor = undefined;
raw = [];
void load(true);
}
function setGroupBy(key: GroupBy) {
if (groupBy === key) return;
groupBy = key;
reload();
}
function toggleDirection() {
reversed = !reversed;
reload();
}
function openResource(item: OutgoingGrantItem) {
if (item.resource_type === 'folder') goto(`/files/${item.resource.id}`);
else window.open(fileInlineUrl(item.resource.id), '_blank', 'noopener');
}
function editSharing(item: OutgoingGrantItem) {
dialogItem = { id: item.resource.id, name: item.resource.name, kind: item.resource_type };
dialogOpen = true;
}
function toggleMenu(grantId: string) {
menuFor = menuFor === grantId ? null : grantId;
}
function closeMenu() {
menuFor = null;
}
function summarize(outcomes: NotifyOutcome[]) {
if (!outcomes || outcomes.length === 0) {
ui.notify(t('myshares.notifySent', 'Notification sent.'), 'success');
return;
}
const sent = outcomes.filter((o) => o.kind === 'sent').length;
const coalesced = outcomes.filter((o) => o.kind === 'coalesced').length;
const rate = 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 (rate > 0)
lines.push(
t('share.notify.rateLimited', { n: rate }, '{{n}} hit the rate limit — try later.')
);
if (skipped > 0)
lines.push(
t('share.notify.skipped', { n: skipped }, '{{n}} skipped (no email / opted out).')
);
ui.notify(
lines.join(' ') || t('myshares.notifySent', 'Notification sent.'),
rate || skipped ? 'info' : 'success'
);
}
// ── Per-grant actions ───────────────────────────────────────────────────
async function changeRole(g: OutgoingResourceGrant, item: OutgoingGrantItem, role: ShareRole) {
closeMenu();
if (g.role === role) return;
try {
await updateGrantRole(
{ type: g.subject_type, id: g.subject_id },
{ type: item.resource_type, id: item.resource.id },
role,
expiryToIso(isoToDate(g.expires_at) || null)
);
g.role = role;
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeExpiry(g: OutgoingResourceGrant, item: OutgoingGrantItem, date: string) {
try {
const iso = expiryToIso(date || null);
await updateGrantRole(
{ type: g.subject_type, id: g.subject_id },
{ type: item.resource_type, id: item.resource.id },
g.role,
iso
);
g.expires_at = iso;
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function notify(g: OutgoingResourceGrant) {
closeMenu();
try {
summarize((await notifyGrantRecipient(g.grant_id)).outcomes);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function removeAccess(g: OutgoingResourceGrant) {
closeMenu();
try {
await revokeGrant(g.grant_id);
dropGrant(g.grant_id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function copyLink(g: OutgoingResourceGrant) {
closeMenu();
try {
const share = await getShareById(g.subject_id);
if (await copyShareLink(share.url)) ui.notify(t('share.copied', 'Link copied'), 'success');
else ui.notify(t('share.copy_failed', 'Could not copy link'), 'error');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function changeLinkExpiry(g: OutgoingResourceGrant, date: string) {
try {
await updateShare(g.subject_id, { expiresAt: date || null });
g.expires_at = expiryToIso(date || null);
raw = [...raw];
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function editLinkPassword(g: OutgoingResourceGrant) {
closeMenu();
const pw = window.prompt(
g.has_password
? t('share.passwordPrompt_clear', 'New password (blank to remove):')
: t('share.passwordPrompt', 'Set a password:')
);
if (pw === null) return;
try {
await updateShare(g.subject_id, { password: pw || null });
g.has_password = !!pw;
raw = [...raw];
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 deleteLink(g: OutgoingResourceGrant) {
closeMenu();
try {
await deleteShare(g.subject_id);
dropGrant(g.grant_id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
/** Remove a grant locally, pruning now-empty resources. */
function dropGrant(grantId: string) {
raw = raw
.map((item) => ({ ...item, grants: item.grants.filter((g) => g.grant_id !== grantId) }))
.filter((item) => item.grants.length > 0);
}
function linkLabel(g: OutgoingResourceGrant): string {
return `${t('share.link', 'Link')} · …${g.subject_id.slice(-4)} · ${g.subject_display}`;
}
function resourceIcon(item: OutgoingGrantItem): string {
return item.resource_type === 'folder'
? 'folder'
: iconNameFromClass((item.resource as FileItem | FolderItem).icon_class);
}
const isEmpty = $derived(!loading && raw.length === 0 && !error);
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared', 'Shared')} · OxiCloud</title></svelte:head>
<svelte:window onclick={() => menuFor && closeMenu()} />
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<div class="page-sticky-header">
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<ListToolbar
groups={GROUP_BYS}
{groupBy}
{reversed}
ongroup={(key) => setGroupBy(key as GroupBy)}
ondirection={toggleDirection}
showViewToggle={false}
/>
</div>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared.empty', "You haven't shared anything yet.")}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.subject
? t('shared.with', { who: item.subject }, 'Shared with {{who}}')
: item.resource.path}
date={formatDate(item.first_shared_at)}
/>
{/each}
</ResourceListShell>
{#if error}
<div class="empty-state">
<Icon name="exclamation-circle" class="empty-state-icon empty-state-icon--error" />
<p>{error}</p>
</div>
{:else if isEmpty}
<div class="empty-state">
<Icon name="share-alt" class="empty-state-icon" />
<p>{t('myshares.emptyStateTitle', "You haven't shared anything yet")}</p>
<p class="empty-state__hint">
{t('myshares.emptyStateDesc', 'Items you share with others will appear here')}
</p>
</div>
{:else}
<div class="ms-lanes">
{#each lanes as lane (lane.key)}
<section class="ms-lane">
<header class="ms-lane__header">
{#if lane.header.kind === 'resource'}
{@const laneItem = lane.header.item}
<button class="ms-lane__resource" onclick={() => openResource(laneItem)}>
<Icon name={resourceIcon(laneItem)} />
<span class="ms-lane__name">{laneItem.resource.name}</span>
</button>
<button class="btn btn-secondary ms-lane__edit" onclick={() => editSharing(laneItem)}>
<Icon name="pencil-alt" />
{t('myshares.editSharing', 'Edit sharing')}
</button>
{:else}
<span class="ms-lane__subject">
<Icon
name={lane.header.kind === 'user'
? 'user'
: lane.header.kind === 'group'
? 'user-group'
: lane.header.kind === 'linkPassword'
? 'lock'
: 'link'}
/>
<span class="ms-lane__name">{laneTitle(lane.header)}</span>
</span>
{/if}
</header>
<ul class="ms-rows">
{#each lane.rows as { grant, item } (grant.grant_id)}
{@const tier = expiryTier(grant.expires_at)}
<li class="ms-row" class:ms-row--expired={tier === 'expired'}>
<!-- Identity -->
<span class="ms-row__identity">
{#if (grant.subject_type === 'user' || grant.subject_type === 'group') && groupBy === 'sharedWith'}
<button class="ms-link-btn" onclick={() => openResource(item)}>
<Icon name={resourceIcon(item)} />
<span class="ms-row__name">{item.resource.name}</span>
</button>
{:else if grant.subject_type === 'user'}
<Icon name="user" />
<span class="ms-row__name">{resolveLabel('user', grant.subject_id)}</span>
{:else if grant.subject_type === 'group'}
<Icon name="user-group" />
<span class="ms-row__name">{resolveLabel('group', grant.subject_id)}</span>
{:else}
<button
class="ms-chip ms-chip--link"
class:ms-chip--locked={grant.has_password}
onclick={() => copyLink(grant)}
title={t('share.copyLink', 'Copy link')}
>
<Icon name={grant.has_password ? 'lock' : 'link'} />
<span class="ms-row__name">{linkLabel(grant)}</span>
</button>
{#if groupBy === 'sharedWith'}
<span class="ms-arrow">→</span>
<button class="ms-link-btn" onclick={() => openResource(item)}>
<Icon name={resourceIcon(item)} />
<span>{item.resource.name}</span>
</button>
{/if}
{/if}
</span>
<!-- Role pill (not shown for token subjects) -->
{#if grant.subject_type !== 'token'}
<span class="ms-role ms-role--{roleMeta(grant.role).v}">
<Icon name={roleMeta(grant.role).icon} />
{roleMeta(grant.role).l}
</span>
{/if}
<!-- Expiry chip -->
<span class="ms-expiry ms-expiry--{tier}" title={expiryLabel(grant.expires_at)}>
<Icon name={grant.expires_at ? 'clock' : 'infinity'} />
{expiryLabel(grant.expires_at)}
</span>
<!-- Kebab -->
<div class="ms-kebab">
<button
class="btn-icon"
aria-label={t('myshares.manageAccess', 'Manage access')}
aria-haspopup="menu"
aria-expanded={menuFor === grant.grant_id}
onclick={(e) => {
e.stopPropagation();
toggleMenu(grant.grant_id);
}}><Icon name="ellipsis-v" /></button
>
{#if menuFor === grant.grant_id}
<div
class="ms-menu"
role="menu"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeMenu()}
>
{#if grant.subject_type === 'user' || grant.subject_type === 'group'}
<button class="ms-menu__item" role="menuitem" onclick={() => notify(grant)}>
<Icon name="paper-plane" />
{grant.subject_type === 'group'
? t('myshares.notifyGroupMembers', 'Notify group members')
: grant.is_external
? t('myshares.resendInvitation', 'Resend invitation email')
: t('myshares.notifyByEmail', 'Notify by email')}
</button>
<div class="ms-menu__sep"></div>
{#each ROLES as r (r.v)}
<button
class="ms-menu__item"
class:ms-menu__item--current={grant.role === r.v}
role="menuitem"
onclick={() => changeRole(grant, item, r.v)}
>
<Icon name={grant.role === r.v ? 'check' : r.icon} />
{r.l}
</button>
{/each}
<div class="ms-menu__sep"></div>
<div class="ms-menu__field">
<span class="ms-menu__label">{t('share.expiry', 'Expiry')}</span>
<input
type="date"
class="ms-menu__date"
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeExpiry(grant, item, (e.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="ms-menu__sep"></div>
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
onclick={() => removeAccess(grant)}
>
<Icon name="user-xmark" />
{t('myshares.removeAccess', 'Remove access')}
</button>
{:else}
<button class="ms-menu__item" role="menuitem" onclick={() => copyLink(grant)}>
<Icon name="copy" />
{t('myshares.copyLink', 'Copy link')}
</button>
<div class="ms-menu__sep"></div>
<div class="ms-menu__field">
<span class="ms-menu__label">{t('share.expiry', 'Expiry')}</span>
<input
type="date"
class="ms-menu__date"
value={isoToDate(grant.expires_at)}
onchange={(e) =>
changeLinkExpiry(grant, (e.currentTarget as HTMLInputElement).value)}
/>
</div>
<button
class="ms-menu__item"
role="menuitem"
onclick={() => editLinkPassword(grant)}
>
<Icon name={grant.has_password ? 'lock' : 'lock-open'} />
{grant.has_password
? t('share.changePassword', 'Change password')
: t('share.addPassword', 'Add password')}
</button>
<div class="ms-menu__sep"></div>
<button
class="ms-menu__item ms-menu__item--danger"
role="menuitem"
onclick={() => deleteLink(grant)}
>
<Icon name="trash" />
{t('myshares.deleteLink', 'Delete link')}
</button>
{/if}
</div>
{/if}
</div>
</li>
{/each}
</ul>
</section>
{/each}
{#if cursor}
<button class="btn btn-secondary ms-more" onclick={() => load(false)} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
</div>
{/if}
<ShareDialog bind:open={dialogOpen} item={dialogItem} />
<style>
.page-title {
.ms-lanes {
display: flex;
flex-direction: column;
gap: var(--space-4);
padding-top: var(--space-3);
}
.ms-lane {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: visible;
}
.ms-lane__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-border-faint, var(--color-border));
background: var(--color-bg-muted);
}
.ms-lane__resource,
.ms-lane__subject {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-weight: var(--weight-semibold, 600);
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
min-width: 0;
}
.ms-lane__subject {
cursor: default;
}
.ms-lane__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-lane__edit {
display: inline-flex;
align-items: center;
gap: var(--space-1);
flex: none;
}
.ms-rows {
list-style: none;
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
padding: 0;
display: flex;
flex-direction: column;
}
.ms-row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border-top: 1px solid var(--color-border-faint, var(--color-border));
}
.ms-row:first-child {
border-top: none;
}
.ms-row--expired {
opacity: 0.6;
}
.ms-row__identity {
display: flex;
align-items: center;
gap: var(--space-2);
flex: 1;
min-width: 0;
}
.ms-row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-link-btn,
.ms-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
min-width: 0;
padding: 0;
}
.ms-chip--link {
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill, 999px);
}
.ms-chip--locked {
color: var(--color-accent);
}
.ms-arrow {
color: var(--color-text-muted);
}
/* Role pill */
.ms-role {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
font-size: var(--text-sm);
background: var(--color-bg-muted);
color: var(--color-text-secondary);
flex: none;
}
.ms-role--admin {
background: var(--color-warning-bg, var(--color-bg-muted));
color: var(--color-warning-text-amber, var(--color-text-secondary));
}
.ms-role--editor {
background: var(--color-accent-bg, var(--color-bg-muted));
color: var(--color-accent-text, var(--color-text-secondary));
}
/* Expiry chip tiers */
.ms-expiry {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, 999px);
font-size: var(--text-sm);
color: var(--color-text-muted);
background: var(--color-bg-muted);
flex: none;
}
.ms-expiry--soon {
color: var(--color-warning-text-amber, var(--color-text-secondary));
background: var(--color-warning-bg, var(--color-bg-muted));
}
.ms-expiry--expired {
color: var(--color-danger-text);
background: var(--color-danger-bg, var(--color-bg-muted));
}
/* Kebab + menu */
.ms-kebab {
position: relative;
flex: none;
}
.btn-icon {
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-secondary);
cursor: pointer;
}
.btn-icon:hover {
background: var(--color-bg-hover);
}
.ms-menu {
position: absolute;
right: 0;
top: 100%;
z-index: 50;
min-width: 14rem;
margin-top: var(--space-1);
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);
}
.ms-menu__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;
}
.ms-menu__item:hover {
background: var(--color-bg-hover);
}
.ms-menu__item--current {
font-weight: var(--weight-semibold, 600);
}
.ms-menu__item--danger {
color: var(--color-danger-text);
}
.ms-menu__sep {
height: 1px;
margin: var(--space-1) 0;
background: var(--color-border);
}
.ms-menu__field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
}
.ms-menu__label {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.ms-menu__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);
}
.ms-more {
margin: var(--space-3) auto 0;
}
.empty-state__hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.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>
+176 -56
View File
@@ -1,62 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
deleteTrashItem,
emptyTrash,
expiryChip,
fetchTrashPage,
remainingDaysBucket,
restoreTrashItem
} from '$lib/api/endpoints/trash';
import type { TrashResourceItem } from '$lib/api/types';
import { dateBucket, sizeBucket, typeLabel } from '$lib/api/endpoints/favorites';
import type { FileItem, TrashResourceItem } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import ResourceList, {
type GroupByDef,
type ResourceEntry
} from '$lib/components/ResourceList.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<TrashResourceItem[]>([]);
let raw = $state<TrashResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
// Default: items expiring soonest first, grouped by remaining days.
let groupBy = $state('remainingDays');
let reversed = $state(false);
async function load(reset = false) {
const entries = $derived(
raw.map((it): ResourceEntry => {
const isFile = it.resource_type === 'file';
return {
id: it.resource.id,
name: it.resource.name,
kind: it.resource_type,
iconClass: it.resource.icon_class,
path: it.resource.path,
size: isFile ? (it.resource as FileItem).size : null,
// `date` carries the deletion date — rendered as an expiry chip.
date: it.deletion_date,
category: isFile ? it.resource.category : 'Folder',
modifiedAt: it.trashed_at
};
})
);
const groupBys: GroupByDef[] = [
{ key: '', label: t('files.name', 'Name'), orderBy: 'name' },
{
key: 'remainingDays',
label: t('trash.groupby.remaining_days', 'Remaining days'),
orderBy: 'deletion_date',
bucketOf: (e) => remainingDaysBucket(e.date)
},
{
key: 'type',
label: t('groupby.type', 'Type'),
orderBy: 'type',
bucketOf: (e) => e.category ?? 'other',
labelOf: (k) => typeLabel(k)
},
{
key: 'size',
label: t('groupby.size', 'Size'),
orderBy: 'size',
bucketOf: (e) => sizeBucket(e.kind === 'folder' ? null : e.size)
},
{
key: 'trashedTime',
label: t('trash.groupby.trashed_time', 'Trashed time'),
orderBy: 'trashed_at',
bucketOf: (e) => dateBucket(e.modifiedAt)
}
];
function orderByForGroup(): string {
return groupBys.find((g) => g.key === groupBy)?.orderBy ?? 'deletion_date';
}
async function load(reset = false, orderBy = 'deletion_date', rev = reversed) {
loading = true;
error = null;
try {
const page = await fetchTrashPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
const page = await fetchTrashPage({
cursor: reset ? undefined : cursor,
orderBy,
reverse: rev,
resourceTypes: ['file', 'folder']
});
raw = reset ? page.items : [...raw, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
console.error('trash: load error', e);
error = t('errors_loadFailed', 'Failed to load items');
} finally {
loading = false;
}
}
async function restore(item: TrashResourceItem) {
/** Re-fetch from the top so pagination + grouping stay correct after a mutation. */
async function reloadFromTop() {
cursor = undefined;
await load(true, orderByForGroup());
}
async function restore(entry: ResourceEntry) {
try {
await restoreTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await restoreTrashItem(entry.id);
ui.notify(t('trash.restored', 'Restored'), 'success');
await reloadFromTop();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purge(item: TrashResourceItem) {
if (!confirm(t('trash.confirm_delete', 'Permanently delete this item?'))) return;
async function purge(entry: ResourceEntry) {
const ok = await confirmDialog({
title: t('trash.delete', 'Delete permanently'),
message: t('trash.confirm_delete', 'Permanently delete this item? This cannot be undone.'),
confirmText: t('trash.delete', 'Delete'),
danger: true
});
if (!ok) return;
try {
await deleteTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
await deleteTrashItem(entry.id);
await reloadFromTop();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purgeAll() {
if (!confirm(t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'))) return;
const ok = await confirmDialog({
title: t('trash.empty_action', 'Empty trash'),
message: t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'),
confirmText: t('trash.empty_action', 'Empty trash'),
danger: true
});
if (!ok) return;
try {
await emptyTrash();
items = [];
raw = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
@@ -68,60 +152,96 @@
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.trash', 'Trash')}</h1>
<ResourceListShell
<ResourceList
title={t('nav.trash', 'Trash')}
items={entries}
{loading}
{error}
empty={items.length === 0}
emptyText={t('trash.empty', 'Trash is empty.')}
emptyIcon="trash"
emptyText={t('trash.empty_state', 'Trash is empty')}
hasMore={!!cursor}
onloadmore={() => load(false)}
onloadmore={() => load(false, orderByForGroup())}
pathLabel={t('trash.original_location', 'Original location')}
dateLabel={t('trash.remaining', 'Remaining')}
{groupBys}
bind:groupBy
bind:reversed
onreload={(orderBy, rev) => {
cursor = undefined;
load(true, orderBy, rev);
}}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn link-btn--danger" onclick={purgeAll}>
{#if entries.length > 0}
<button class="btn btn-danger" onclick={purgeAll}>
<Icon name="trash" />
{t('trash.empty_action', 'Empty trash')}
</button>
{/if}
{/snippet}
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.deletion_date)}
{#snippet dateCell(entry)}
{@const chip = expiryChip(entry.date)}
<span class="expiry-chip expiry-chip--{chip.tier}">
<Icon name={chip.icon} class="expiry-chip__icon" />
{chip.label}
</span>
{/snippet}
{#snippet actions(entry)}
<button class="btn-action" title={t('trash.restore', 'Restore')} onclick={() => restore(entry)}>
<Icon name="undo" />
</button>
<button
class="btn-action btn-action--delete"
title={t('trash.delete', 'Delete permanently')}
onclick={() => purge(entry)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => restore(item)}>
{t('trash.restore', 'Restore')}
</button>
<button class="link-btn link-btn--danger" onclick={() => purge(item)}>
{t('trash.delete', 'Delete')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<Icon name="trash" />
</button>
{/snippet}
</ResourceList>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
/* Tiered expiry chip — ported from static/css expiryChip styles. */
.expiry-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-pill, var(--radius-md));
font-size: var(--text-xs);
font-weight: var(--weight-medium, 500);
white-space: nowrap;
background: var(--color-bg-muted);
color: var(--color-text-secondary);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
.expiry-chip :global(.expiry-chip__icon) {
font-size: 0.85em;
}
.link-btn--danger {
.expiry-chip--never {
background: var(--color-bg-muted);
color: var(--color-text-faint);
}
.expiry-chip--caution {
background: var(--color-warning-bg);
color: var(--color-warning-text-amber);
}
.expiry-chip--soon {
background: var(--color-warning-orange-bg);
color: var(--color-warning-text-orange);
}
.expiry-chip--urgent {
background: var(--color-danger-bg);
color: var(--color-danger-text);
}
.expiry-chip--expired {
background: var(--color-danger-bg);
color: var(--color-danger-text);
font-weight: var(--weight-semibold);
}
</style>
+343
View File
@@ -0,0 +1,343 @@
/**
* Incremental BLAKE3 hasher.
*
* ```js
* const h = new Blake3Hasher();
* h.update(chunkBytes); // repeat per slice
* const hex = h.finalizeHex();
* ```
*/
export class Blake3Hasher {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
Blake3HasherFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_blake3hasher_free(ptr, 0);
}
/**
* Bytes hashed so far — lets the worker report progress without
* tracking its own counter.
* @returns {number}
*/
count() {
const ret = wasm.blake3hasher_count(this.__wbg_ptr);
return ret;
}
/**
* Finish and return the lowercase hex digest (64 chars). The hasher
* can keep receiving `update` calls afterwards (BLAKE3 finalization
* is non-destructive), but the frontend treats it as terminal.
* @returns {string}
*/
finalizeHex() {
let deferred1_0;
let deferred1_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.blake3hasher_finalizeHex(retptr, this.__wbg_ptr);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred1_0 = r0;
deferred1_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred1_0, deferred1_1, 1);
}
}
/**
* Create a fresh hasher.
*/
constructor() {
const ret = wasm.blake3hasher_new();
this.__wbg_ptr = ret;
Blake3HasherFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Feed one slice of the file.
* @param {Uint8Array} data
*/
update(data) {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export);
const len0 = WASM_VECTOR_LEN;
wasm.blake3hasher_update(this.__wbg_ptr, ptr0, len0);
}
}
if (Symbol.dispose) Blake3Hasher.prototype[Symbol.dispose] = Blake3Hasher.prototype.free;
/**
* Incremental FastCDC chunker + whole-file BLAKE3, for the delta-upload
* worker. Feed the file in slices; every call returns the chunks that
* became FINAL; `finish()` flushes the tail and returns the file hash.
*
* ```js
* const c = new DeltaChunker();
* for (const slice of slices) {
* for (const [h, s] of JSON.parse(c.update(bytes))) { … }
* }
* const { chunks, file_hash } = JSON.parse(c.finish());
* ```
*
* Correctness of the incremental split: FastCDC decides each cut by
* scanning at most `CDC_MAX_CHUNK` bytes from the chunk's start. When
* the chunker runs over the buffered prefix of a longer file, every
* produced chunk except the LAST ended on a content/max-size condition
* — its decision window was fully available, so the full-file chunker
* makes the same cut. Only the last chunk (cut by "end of buffer") is
* provisional: it stays buffered and is re-examined when more bytes
* arrive. By induction the emitted boundaries equal a single FastCDC
* pass over the whole file — the mirror test below proves it.
*/
export class DeltaChunker {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
DeltaChunkerFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_deltachunker_free(ptr, 0);
}
/**
* Flush the provisional tail and return
* `{"chunks":[["<hex>",size]…],"file_hash":"<hex>","total":N}`.
* `chunks` holds at most one entry (the tail); an empty file has none
* and its `file_hash` is BLAKE3 of the empty input.
* @returns {string}
*/
finish() {
let deferred1_0;
let deferred1_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
wasm.deltachunker_finish(retptr, this.__wbg_ptr);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred1_0 = r0;
deferred1_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred1_0, deferred1_1, 1);
}
}
/**
* Create a chunker with the server's CDC parameters.
*/
constructor() {
const ret = wasm.deltachunker_new();
this.__wbg_ptr = ret;
DeltaChunkerFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Feed one slice. Returns a JSON array of the chunks that became
* final: `[["<blake3-hex>", size], …]` (possibly empty).
* @param {Uint8Array} data
* @returns {string}
*/
update(data) {
let deferred2_0;
let deferred2_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export);
const len0 = WASM_VECTOR_LEN;
wasm.deltachunker_update(retptr, this.__wbg_ptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred2_0 = r0;
deferred2_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred2_0, deferred2_1, 1);
}
}
}
if (Symbol.dispose) DeltaChunker.prototype[Symbol.dispose] = DeltaChunker.prototype.free;
/**
* One-shot convenience for small buffers.
* @param {Uint8Array} data
* @returns {string}
*/
export function blake3Hex(data) {
let deferred2_0;
let deferred2_1;
try {
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_export);
const len0 = WASM_VECTOR_LEN;
wasm.blake3Hex(retptr, ptr0, len0);
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
deferred2_0 = r0;
deferred2_1 = r1;
return getStringFromWasm0(r0, r1);
} finally {
wasm.__wbindgen_add_to_stack_pointer(16);
wasm.__wbindgen_export2(deferred2_0, deferred2_1, 1);
}
}
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_throw_bbadd78c1bac3a77: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
};
return {
__proto__: null,
"./oxicloud_hash_wasm_bg.js": import0,
};
}
const Blake3HasherFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_blake3hasher_free(ptr, 1));
const DeltaChunkerFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_deltachunker_free(ptr, 1));
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getStringFromWasm0(ptr, len) {
return decodeText(ptr >>> 0, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasmInstance, wasm;
function __wbg_finalize_init(instance, module) {
wasmInstance = instance;
wasm = instance.exports;
wasmModule = module;
cachedDataViewMemory0 = null;
cachedUint8ArrayMemory0 = null;
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('oxicloud_hash_wasm_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
Binary file not shown.
+330
View File
@@ -0,0 +1,330 @@
/**
* OxiCloud — delta-upload worker ("upload only what changed").
*
* Runs the whole client side of the delta protocol off the main thread:
*
* read 8 MiB slices ─► FastCDC chunk + BLAKE3 (WASM, same crate and
* parameters as the server) ─► negotiate hash batches ─► upload only
* the missing chunks (framed, bounded concurrency) ─► commit.
*
* The stages OVERLAP: negotiation of batch N and uploads of its missing
* chunks run while batch N+1 is still being hashed, so wall-clock time
* approaches max(hash time, upload time) instead of their sum. RAM stays
* flat: chunk bytes are re-sliced from the File at upload time, never
* hoarded.
*
* Protocol with the spawner:
* in : { file: File, folderId: string, name: string, csrfToken: string }
* out : { type: 'progress', hashedBytes, reusedBytes, uploadedBytes, totalBytes }
* { type: 'done', status, body } — conclusive HTTP outcome
* { type: 'fallback', reason } — do a plain byte upload
*/
// Absolute URLs on purpose: vendors/workers are served verbatim in both
// dev and the static build (served verbatim from /static).
const WASM_GLUE_URL = '/vendors/hash-wasm/oxicloud_hash_wasm.js';
/** File read granularity — large enough to amortize Blob→ArrayBuffer. */
const SLICE_BYTES = 8 * 1024 * 1024;
/** Negotiate after this many freshly hashed chunks (~64 MiB of content). */
const NEGOTIATE_BATCH = 256;
/** Group missing chunks into PUT bodies of at most this many bytes. */
const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024;
/** Concurrent chunk-PUT requests. */
const UPLOAD_CONCURRENCY = 2;
/** Re-commit attempts when the server answers 409 still_missing. */
const COMMIT_RETRIES = 2;
/**
* Typed view of the dedicated-worker global scope (jsconfig targets the
* DOM lib, where `self` is a Window — cast to what this worker uses).
* @type {{ onmessage: ((event: MessageEvent) => void) | null,
* postMessage: (message: unknown) => void }}
*/
const workerScope = /** @type {any} */ (self);
/**
* One chunk occurrence, in file order.
* @typedef {{ h: string, s: number, offset: number }} WorkerChunk
*/
/** @returns {Promise<any>} the initialized WASM module */
async function loadWasm() {
const mod = await import(WASM_GLUE_URL);
await mod.default();
return mod;
}
workerScope.onmessage = async (event) => {
const { file, folderId, name, csrfToken } = /** @type {{ file: File, folderId: string, name: string, csrfToken: string }} */ (event.data);
/** @param {string} reason */
const fallback = (reason) => workerScope.postMessage({ type: 'fallback', reason });
/** @type {Record<string, string>} */
const mutHeaders = { 'Content-Type': 'application/json' };
if (csrfToken) mutHeaders['X-CSRF-Token'] = csrfToken;
let wasm;
try {
wasm = await loadWasm();
} catch (err) {
fallback(`wasm unavailable: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// ── Shared pipeline state ─────────────────────────────────────
/** @type {WorkerChunk[]} */
const chunks = []; // every occurrence, in file order
/** @type {Set<string>} */
const seenForNegotiate = new Set(); // distinct hashes already sent to negotiate
let reusedBytes = 0;
let uploadedBytes = 0;
let hashedBytes = 0;
let failed = /** @type {string | null} */ (null);
let lastProgress = 0;
const progress = (force = false) => {
const now = Date.now();
if (!force && now - lastProgress < 150) return;
lastProgress = now;
workerScope.postMessage({
type: 'progress',
hashedBytes,
reusedBytes,
uploadedBytes,
totalBytes: file.size
});
};
// ── Upload stage: bounded-concurrency drain of uploadByHash ──
/** @type {WorkerChunk[]} */
const uploadQueue = [];
/** @type {Promise<void>[]} */
const uploadWorkers = [];
let uploadsClosed = false;
/** @type {(() => void) | null} */
let wakeUploader = null;
const signalUploaders = () => {
if (wakeUploader) {
const w = wakeUploader;
wakeUploader = null;
w();
}
};
/** Encode a batch of chunks as [u32 BE len][bytes] frames. */
const encodeFrames = async (/** @type {WorkerChunk[]} */ batch) => {
const total = batch.reduce((n, c) => n + 4 + c.s, 0);
const wire = new Uint8Array(total);
const view = new DataView(wire.buffer);
let at = 0;
for (const c of batch) {
// eslint-disable-next-line no-await-in-loop -- sequential by design: constant RAM
const bytes = new Uint8Array(await file.slice(c.offset, c.offset + c.s).arrayBuffer());
view.setUint32(at, c.s, false);
wire.set(bytes, at + 4);
at += 4 + c.s;
}
return wire;
};
const uploadLoop = async () => {
while (!failed) {
// Take up to UPLOAD_BATCH_BYTES from the queue.
/** @type {WorkerChunk[]} */
const batch = [];
let bytes = 0;
while (uploadQueue.length > 0 && bytes < UPLOAD_BATCH_BYTES) {
const c = /** @type {WorkerChunk} */ (uploadQueue.shift());
batch.push(c);
bytes += c.s;
}
if (batch.length === 0) {
if (uploadsClosed) return;
// eslint-disable-next-line no-await-in-loop -- queue wait
await new Promise((resolve) => {
wakeUploader = /** @type {() => void} */ (resolve);
});
continue;
}
try {
// eslint-disable-next-line no-await-in-loop -- bounded by pool size
const wire = await encodeFrames(batch);
// eslint-disable-next-line no-await-in-loop -- bounded by pool size
const response = await fetch('/api/files/delta/chunks', {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: wire
});
if (!response.ok) {
failed = `chunk PUT failed (HTTP ${response.status})`;
return;
}
for (const c of batch) uploadedBytes += c.s;
progress();
} catch (err) {
failed = `chunk PUT failed: ${err instanceof Error ? err.message : String(err)}`;
return;
}
}
};
for (let i = 0; i < UPLOAD_CONCURRENCY; i++) uploadWorkers.push(uploadLoop());
// ── Negotiate stage ───────────────────────────────────────────
/** @type {Promise<void>[]} */
const negotiations = [];
const negotiate = (/** @type {WorkerChunk[]} */ fresh) => {
if (fresh.length === 0 || failed) return;
negotiations.push(
(async () => {
try {
const response = await fetch('/api/files/delta/negotiate', {
method: 'POST',
headers: mutHeaders,
body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) })
});
if (!response.ok) {
failed = failed || `negotiate failed (HTTP ${response.status})`;
return;
}
const missing = new Set(/** @type {{missing: string[]}} */ (await response.json()).missing);
for (const c of fresh) {
if (missing.has(c.h)) {
uploadQueue.push(c);
} else {
reusedBytes += c.s;
}
}
signalUploaders();
progress();
} catch (err) {
failed = failed || `negotiate failed: ${err instanceof Error ? err.message : String(err)}`;
}
})()
);
};
// ── Chunking stage (drives the other two) ────────────────────
try {
const chunker = new wasm.DeltaChunker();
/** @type {WorkerChunk[]} */
let freshBatch = [];
let offset = 0;
/** @param {[string, number][]} emitted */
const onChunks = (emitted) => {
for (const [h, s] of emitted) {
/** @type {WorkerChunk} */
const chunk = { h, s, offset };
offset += s;
chunks.push(chunk);
if (seenForNegotiate.has(h)) {
// Repeated content inside the same file: the first
// occurrence decides upload vs reuse; later ones are
// pure reuse for accounting.
reusedBytes += s;
} else {
seenForNegotiate.add(h);
freshBatch.push(chunk);
if (freshBatch.length >= NEGOTIATE_BATCH) {
negotiate(freshBatch);
freshBatch = [];
}
}
}
};
for (let read = 0; read < file.size && !failed; read += SLICE_BYTES) {
const end = Math.min(read + SLICE_BYTES, file.size);
// eslint-disable-next-line no-await-in-loop -- sequential by design: constant RAM
const slice = new Uint8Array(await file.slice(read, end).arrayBuffer());
onChunks(JSON.parse(chunker.update(slice)));
hashedBytes = end;
progress();
}
const fin = JSON.parse(chunker.finish());
chunker.free();
onChunks(fin.chunks);
negotiate(freshBatch);
const fileHash = /** @type {string} */ (fin.file_hash);
hashedBytes = file.size;
progress(true);
// ── Drain: negotiations → uploads → commit ───────────────
await Promise.all(negotiations);
uploadsClosed = true;
signalUploaders();
await Promise.all(uploadWorkers);
if (failed) {
fallback(failed);
return;
}
const commitBody = {
file_hash: fileHash,
chunks: chunks.map(({ h, s }) => ({ h, s })),
name,
folder_id: folderId
};
for (let attempt = 0; ; attempt++) {
// eslint-disable-next-line no-await-in-loop -- retry loop
const response = await fetch('/api/files/delta/commit', {
method: 'POST',
headers: mutHeaders,
body: JSON.stringify(commitBody)
});
/** @type {any} */
let body = null;
try {
// eslint-disable-next-line no-await-in-loop -- retry loop
body = await response.json();
} catch (_) {}
const stillMissing = response.status === 409 && Array.isArray(body?.still_missing);
if (stillMissing && attempt < COMMIT_RETRIES) {
// GC race or a chunk we wrongly assumed claimable: upload
// exactly what the server names and try again.
const byHash = new Map(chunks.map((c) => [c.h, c]));
/** @type {WorkerChunk[]} */
const retry = [];
for (const h of body.still_missing) {
const c = byHash.get(h);
if (!c) {
fallback('server requested an unknown chunk');
return;
}
retry.push(c);
}
const wire = await encodeFrames(retry);
// eslint-disable-next-line no-await-in-loop -- retry loop
const put = await fetch('/api/files/delta/chunks', {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: wire
});
if (!put.ok) {
fallback(`retry chunk PUT failed (HTTP ${put.status})`);
return;
}
for (const c of retry) uploadedBytes += c.s;
progress(true);
continue;
}
// Conclusive: 201 created, or a real error (quota, name
// conflict, validation). The spawner maps it to the uploaders'
// UploadAnswer contract.
workerScope.postMessage({ type: 'done', status: response.status, body });
return;
}
} catch (err) {
fallback(err instanceof Error ? err.message : String(err));
}
};
+1 -1
View File
@@ -156,7 +156,7 @@ api-test:
bash tests/webdav/run.sh
# ---------------------------------------------------------------------------
# New SvelteKit frontend (frontend/). The legacy vanilla frontend (static/)
# New SvelteKit frontend (frontend/). The original vanilla frontend (static/)
# and its `front-*` recipes remain until the Phase 5 cutover; these `fe-*`
# recipes drive the rewrite in the meantime.
# ---------------------------------------------------------------------------