visual continunity
This commit is contained in:
+1
-1
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/']
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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() || '' });
|
||||
});
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
@@ -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,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('?');
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
@@ -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));
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user