Merge pull request #486 from AtalayaLabs/claude/gallant-pasteur-0l8bm9

This commit is contained in:
Dionisio Pozo
2026-06-19 15:11:25 +02:00
committed by GitHub
172 changed files with 54369 additions and 15863 deletions
+23 -53
View File
@@ -32,15 +32,7 @@ jobs:
with:
filters: |
frontend:
- 'static/**'
- 'biome.json'
- '.grit'
- '.stylelintrc.json'
- 'jsconfig.json'
# Audit scripts that gate frontend correctness — touch
# them and the frontend job must re-run.
- 'tools/check-missing-translations.py'
- 'tools/check-icons.py'
- 'frontend/**'
backend:
- 'src/**'
- 'Cargo.toml'
@@ -59,55 +51,31 @@ jobs:
- 'src/application/adapters/plugin_user_lifecycle_hook.rs'
frontend-check:
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
name: Frontend — svelte-check, ESLint, Stylelint, Prettier
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Install Biome
uses: biomejs/setup-biome@v2
- name: Run Biome check
run: biome ci static/
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 25
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
# because we are not using package.json
- name: Install Stylelint, TypeScript and plugins
run: |
npm install --global \
stylelint@17 \
postcss@8 \
stylelint-value-no-unknown-custom-properties@6 \
typescript
- name: Install dependencies
run: npm ci
- name: Run Stylelint
run: npx stylelint "static/css/**/*.{css,scss}"
- name: Check (svelte-check + eslint + stylelint + prettier)
run: npm run check
- name: Run TypeScript check
run: tsc -p jsconfig.json --noEmit
- name: Check locale files are at parity with en.json
# Python 3 stdlib only — no setup step needed.
# **Advisory** (non-blocking via `continue-on-error`): a failed
# exit code paints the step red but does not fail the job, so
# unrelated PRs still merge. Re-arm by removing
# `continue-on-error`. Run locally without `--check-only` to see
# the missing keys.
continue-on-error: true
run: python3 tools/check-missing-translations.py --check-only
- name: Check FA icons referenced in static/ are registered
# **Advisory** (non-blocking via `continue-on-error`). Run
# locally without `--check-only` to auto-add missing entries
# from a checked-out Font-Awesome source.
continue-on-error: true
run: python3 tools/check-icons.py --check-only
- name: Unit tests
run: npm run test:unit
rust-fmt:
name: Rustfmt
@@ -269,14 +237,16 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA (Vite -> static-dist/)
working-directory: frontend
run: npm ci && npm run build
- run: cargo build --release
# build.rs runs the deconflict pass and js_bundle_validate() — any
# duplicate declaration or parse error in the JS bundle fails here.
- name: Validate JS bundle (node --check)
# belt-and-suspenders: node --check parses the bundle without executing it.
# Catches SyntaxErrors that OXC's parse check inside build.rs would also
# catch, but gives a human-readable error line in the CI log.
run: node --check static-dist/js/app.*.js
- uses: actions/upload-artifact@v4
with:
name: oxicloud-release
+14 -1
View File
@@ -6,6 +6,16 @@ FROM rust:1.96-alpine3.24 AS base
RUN apk --no-cache upgrade && \
apk add --no-cache musl-dev pkgconfig gcc perl make
# ─── Stage 1b: Build the SvelteKit frontend (Vite) ───────────────────────────
# Produces the SPA in /static-dist. `npm ci` is cached unless the lockfile
# changes; the Rust build no longer bundles assets (see build.rs).
FROM node:24-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ─── Stage 2: Cache dependencies ─────────────────────────────────────────────
FROM base AS cacher
WORKDIR /app
@@ -42,6 +52,9 @@ ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
# test-only bins (e.g. load-seed) even if `required-features` gating
# changes upstream.
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release --bin oxicloud --bin generate-openapi --bin migrate-nfc-filenames
# 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_RUST_ASSETS=1.)
COPY --from=frontend /static-dist ./static-dist
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0
@@ -74,7 +87,7 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
chmod 755 /usr/local/bin/entrypoint.sh
# Copy processed static files (bundled/minified by build.rs in release)
# Copy the built SPA (produced by the Vite frontend stage)
COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static
# Create storage directory with proper permissions
RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage
+9
View File
@@ -40,9 +40,18 @@ fn main() {
println!("cargo:rerun-if-changed=static");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=OXICLOUD_RUST_ASSETS");
git_status();
// 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;
}
// ── Guard: Docker cacher stage has no static/ ────────────────────────────
if !static_dir.exists() {
for name in HTML_INCLUDE {
+1 -1
View File
@@ -22,7 +22,7 @@
cargo-audit
# frontend tooling (no root package.json — these are expected as global bins)
nodejs_22
nodejs_24
biome
typescript # provides `tsc`
stylelint
+8
View File
@@ -0,0 +1,8 @@
node_modules/
/build/
/.svelte-kit/
/package-lock.json.bak
.DS_Store
*.local
vite.config.ts.timestamp-*
vite.config.js.timestamp-*
+2
View File
@@ -0,0 +1,2 @@
engine-strict=false
save-exact=false
+1
View File
@@ -0,0 +1 @@
24
+16
View File
@@ -0,0 +1,16 @@
build/
.svelte-kit/
package/
node_modules/
package.json
package-lock.json
src/lib/i18n/locales/
# vendored / generated — kept byte-faithful to their source
src/lib/styles/base/
src/lib/styles/ported/
src/lib/styles/ported.css
src/lib/icons/registry.ts
static/locales/
static/vendors/
static/workers/
static/basemaps/
+8
View File
@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+8
View File
@@ -0,0 +1,8 @@
build/
.svelte-kit/
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/ported/
src/lib/styles/ported.css
+32
View File
@@ -0,0 +1,32 @@
{
"extends": ["stylelint-config-standard"],
"overrides": [
{
"files": ["**/*.svelte"],
"customSyntax": "postcss-html"
},
{
"files": ["src/lib/styles/base/variables.css"],
"rules": {
"color-no-hex": null,
"color-named": null,
"function-disallowed-list": null
}
}
],
"rules": {
"color-no-hex": true,
"color-named": "never",
"selector-pseudo-class-no-unknown": [true, { "ignorePseudoClasses": ["global"] }],
"property-no-unknown": [true, { "ignoreProperties": ["/^--/"] }],
"function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "hwb"],
"selector-disallowed-list": ["/\\[data-theme/"],
"selector-class-pattern": null,
"custom-property-empty-line-before": null,
"declaration-empty-line-before": null,
"comment-empty-line-before": null,
"no-descending-specificity": null,
"value-keyword-case": null,
"property-no-vendor-prefix": null
}
}
+34
View File
@@ -0,0 +1,34 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
// `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/']
}
);
+6261
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "oxicloud-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^22.19.21",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"postcss-html": "^1.7.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"stylelint": "^16.12.0",
"stylelint-config-standard": "^36.0.1",
"svelte": "^5.16.0",
"svelte-check": "^4.1.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.6",
"vitest": "^3.2.4"
}
}
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en" data-color-scheme>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="color-scheme" content="light dark" />
<!--
Anti-FOUC theme init — runs synchronously before first paint.
Ported from static/js/core/theme-init.js. Keeps the legacy
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
so existing users keep their preference across the migration.
-->
<script>
(function () {
try {
var s = localStorage.getItem('oxicloud_theme');
var h = document.documentElement;
if (s === 'light' || s === 'dark') h.setAttribute('data-color-scheme', s);
else h.removeAttribute('data-color-scheme');
} catch (e) {
/* localStorage unavailable — fall back to OS preference */
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
/**
* Client startup hook. Runs once before the first route renders:
* - wires the API client's session-expired behaviour (clear store + redirect),
* - loads translations for the resolved locale.
*/
import { setSessionExpiredHandler } from '$lib/api/client';
import { initI18n } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
export async function init(): Promise<void> {
setSessionExpiredHandler(() => {
session.reset();
if (typeof window !== 'undefined') {
window.location.href = '/login?source=session_expired';
}
});
await initI18n();
}
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiFetch } from './client';
const ORIGIN = 'https://cloud.example';
function jsonResponse(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), { status });
}
describe('createApiFetch — 401 refresh/retry parity', () => {
let onSessionExpired: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSessionExpired = vi.fn();
});
it('passes through a non-401 response untouched (no refresh)', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true }));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('on 401 refreshes once then retries the original request', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { retried: true })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ retried: true });
expect(rawFetch).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({ method: 'POST' })
);
expect(rawFetch).toHaveBeenCalledTimes(3);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('fires session-expired and throws when refresh fails', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(401)); // refresh fails
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
await expect(apiFetch(`${ORIGIN}/api/files`)).rejects.toThrow('Session expired');
expect(onSessionExpired).toHaveBeenCalledTimes(1);
expect(rawFetch).toHaveBeenCalledTimes(2); // original + refresh, NO retry
});
it('deduplicates concurrent 401s into a single refresh', async () => {
let refreshCalls = 0;
const rawFetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : (input as Request).url;
if (url.includes('/api/auth/refresh')) {
refreshCalls++;
await new Promise((r) => setTimeout(r, 10));
return jsonResponse(200);
}
// First hit per resource is a 401; retries (after refresh) succeed.
return jsonResponse(refreshCalls > 0 ? 200 : 401);
});
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const [a, b] = await Promise.all([
apiFetch(`${ORIGIN}/api/files`),
apiFetch(`${ORIGIN}/api/folders`)
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(refreshCalls).toBe(1); // single shared refresh
});
it('passes cross-origin 401s through without refreshing', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch('https://third-party.example/api/thing');
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1); // no refresh attempt
expect(onSessionExpired).not.toHaveBeenCalled();
});
it.each([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/start',
'/api/auth/device/code',
'/api/s/sometoken'
])('bypasses refresh for auth primitive / public share: %s', async (path) => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}${path}`);
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('retries user-data endpoints under /api/auth/ (e.g. me)', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original /api/auth/me
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { id: 'u1' })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/auth/me`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: 'u1' });
expect(rawFetch).toHaveBeenCalledTimes(3);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Typed API client with transparent 401 → token-refresh → retry.
*
* 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
* request and the retry never re-enter the interceptor (no recursion).
* - Concurrent 401s collapse into a single in-flight `/api/auth/refresh`.
* - Cross-origin responses are passed through untouched.
* - Auth primitives (login/logout/refresh/register/setup/oidc/device) and
* public-share endpoints (/api/s/) bypass the refresh-and-retry path:
* a 401 there is genuine ("bad credentials" / "password required"), not an
* expired access token.
* - When refresh fails, the session-expired handler fires (clear + redirect)
* and the call rejects.
*/
import { getCsrfHeaders } from './csrf';
const REFRESH_ENDPOINT = '/api/auth/refresh';
/** Auth primitives — a 401 here is genuine, never an expired access token. */
const AUTH_PRIMITIVES = [
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/',
'/api/auth/device/'
];
export type FetchFn = typeof fetch;
export interface ApiClientDeps {
/** Underlying fetch used for the real network call (bypasses the interceptor). */
rawFetch: FetchFn;
/** Invoked once when a refresh definitively fails (clear session + redirect). */
onSessionExpired: () => void;
/** Test seam for `window.location.origin`. */
origin?: string;
}
function urlString(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.href;
return input.url ?? '';
}
function isCrossOrigin(urlStr: string, origin: string): boolean {
try {
return new URL(urlStr, origin).origin !== origin;
} catch {
// Unparseable URL — treat as cross-origin so we pass it through untouched.
return true;
}
}
function bypassesRetry(urlStr: string): boolean {
return AUTH_PRIMITIVES.some((p) => urlStr.includes(p)) || urlStr.includes('/api/s/');
}
/**
* Build an isolated apiFetch with its own refresh-dedup state. Used directly in
* tests; the app uses the default singleton below.
*/
export function createApiFetch(deps: ApiClientDeps): FetchFn {
const { rawFetch, onSessionExpired } = deps;
let refreshInFlight: Promise<boolean> | null = null;
async function refresh(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight;
refreshInFlight = (async () => {
try {
const r = await rawFetch(REFRESH_ENDPOINT, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
return r.ok;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();
return refreshInFlight;
}
const apiFetch: FetchFn = async (input, init) => {
const origin = deps.origin ?? globalThis.location?.origin ?? 'http://localhost';
const response = await rawFetch(input, init);
if (response.status !== 401) return response;
const urlStr = urlString(input as RequestInfo | URL);
if (isCrossOrigin(urlStr, origin)) return response;
if (bypassesRetry(urlStr)) return response;
const refreshed = await refresh();
if (!refreshed) {
onSessionExpired();
throw new Error('Session expired');
}
return rawFetch(input, init);
};
return apiFetch;
}
// ── Default singleton ──────────────────────────────────────────────────────
let sessionExpiredHandler: () => void = () => {
if (typeof window !== 'undefined') {
window.location.href = '/login?source=session_expired';
}
};
/** Wire the real session-expired behaviour (clear store + redirect) at startup. */
export function setSessionExpiredHandler(fn: () => void): void {
sessionExpiredHandler = fn;
}
const rawFetch: FetchFn =
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
/** App-wide fetch — route every API call through this. */
export const apiFetch: FetchFn = createApiFetch({
rawFetch,
onSessionExpired: () => sessionExpiredHandler()
});
/** Convenience: fetch JSON, throwing on non-2xx. */
export async function apiJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const res = await apiFetch(input, init);
if (!res.ok) {
throw new ApiError(res.status, res.statusText, input);
}
return (await res.json()) as T;
}
export class ApiError extends Error {
constructor(
readonly status: number,
readonly statusText: string,
readonly resource: RequestInfo | URL
) {
super(`API ${status} ${statusText} for ${urlString(resource as RequestInfo | URL)}`);
this.name = 'ApiError';
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* CSRF double-submit cookie utility — ported from static/js/core/csrf.js.
*
* Reads the `oxicloud_csrf` cookie (NOT HttpOnly) and exposes its value as the
* `X-CSRF-Token` header. The server's `csrf_middleware` validates that the
* header matches the cookie for every mutating (POST/PUT/DELETE/PATCH) request
* authenticated via the HttpOnly session cookie.
*/
export function getCsrfToken(): string {
const match = document.cookie.split('; ').find((row) => row.startsWith('oxicloud_csrf='));
return match ? (match.split('=')[1] ?? '') : '';
}
/** Headers to merge into a mutating request; empty when no token is present. */
export function getCsrfHeaders(): Record<string, string> {
const token = getCsrfToken();
return token ? { 'X-CSRF-Token': token } : {};
}
+379
View File
@@ -0,0 +1,379 @@
/**
* 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';
import type { User } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
const res = await apiFetch(url, {
method,
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(e.message || `${method} ${url} failed: ${res.status}`);
}
}
// ── Users ───────────────────────────────────────────────────────────────
export interface AdminUsersPage {
total: number;
users: User[];
}
export function listUsers(limit: number, offset: number): Promise<AdminUsersPage> {
return apiJson<AdminUsersPage>(`/api/admin/users?limit=${limit}&offset=${offset}`, {
credentials: 'same-origin'
});
}
export interface CreateUserInput {
username: string;
password: string;
/** Optional — the backend auto-generates an address when null/empty. */
email: string | null;
role: string;
quota_bytes: number;
}
export function createUser(input: CreateUserInput): Promise<void> {
return mutate('/api/admin/users', 'POST', input);
}
export function setUserRole(userId: string, role: string): Promise<void> {
return mutate(`/api/admin/users/${userId}/role`, 'PUT', { role });
}
export function setUserActive(userId: string, active: boolean): Promise<void> {
return mutate(`/api/admin/users/${userId}/active`, 'PUT', { active });
}
export function setUserQuota(userId: string, quotaBytes: number): Promise<void> {
return mutate(`/api/admin/users/${userId}/quota`, 'PUT', { quota_bytes: quotaBytes });
}
export function resetUserPassword(userId: string, newPassword: string): Promise<void> {
return mutate(`/api/admin/users/${userId}/password`, 'PUT', { new_password: newPassword });
}
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 {
id: string;
name: string;
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 {
/** false when the plugin subsystem is disabled (server returns 503). */
available: boolean;
enabled?: boolean;
plugins: PluginInfo[];
}
export async function listPlugins(): Promise<PluginsResult> {
const res = await apiFetch('/api/admin/plugins', { credentials: 'same-origin' });
if (res.status === 503) return { available: false, plugins: [] };
if (!res.ok) throw new Error(`plugins failed: ${res.status}`);
const data = (await res.json()) as { enabled?: boolean; plugins?: PluginInfo[] };
return { available: true, enabled: data.enabled, plugins: data.plugins ?? [] };
}
export function setPluginEnabled(id: string, enabled: boolean): Promise<void> {
return mutate(`/api/admin/plugins/${encodeURIComponent(id)}/enabled`, 'PUT', { enabled });
}
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'
});
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Auth endpoints. The 401-refresh/dedup behaviour lives in apiFetch; the auth
* primitives here intentionally bypass it (see client.ts) so a 401 surfaces as
* a genuine failure to the caller.
*/
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { AuthResponse, User } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
/**
* Probe the current session. Uses the raw `fetch` (NOT apiFetch) on purpose:
* a 401 here just means "not logged in" and must not trigger the global
* refresh-and-redirect (which would bounce the app in a refresh loop on the
* unauthenticated initial load). Returns null when unauthenticated.
*/
export async function fetchMe(): Promise<User | null> {
const res = await fetch('/api/auth/me', { credentials: 'same-origin' });
if (res.status === 401) return null;
if (!res.ok) throw new Error(`/api/auth/me failed: ${res.status}`);
return (await res.json()) as User;
}
/**
* Attempt a single token refresh (raw fetch, no interceptor). Returns whether
* it succeeded. Used by the startup probe; mid-session refresh is handled
* transparently by apiFetch for all other endpoints.
*/
export async function tryRefresh(): Promise<boolean> {
try {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
return res.ok;
} catch {
return false;
}
}
export async function login(emailOrUsername: string, password: string): Promise<AuthResponse> {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ username: emailOrUsername, password })
});
if (!res.ok) throw new Error(`login failed: ${res.status}`);
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',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Batch operations (/api/batch/*). Used for multi-item copy — move and delete
* already have per-item endpoints the files view loops over, but copy only
* exists as a batch endpoint on the backend.
*/
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
async function post(url: string, body: unknown): Promise<void> {
const res = await apiFetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string; message?: string };
throw new Error(e.error || e.message || `${url} failed: ${res.status}`);
}
}
export function copyFiles(fileIds: string[], targetFolderId: string | null): Promise<void> {
if (fileIds.length === 0) return Promise.resolve();
return post('/api/batch/files/copy', { file_ids: fileIds, target_folder_id: targetFolderId });
}
export function copyFolders(folderIds: string[], targetFolderId: string | null): Promise<void> {
if (folderIds.length === 0) return Promise.resolve();
return post('/api/batch/folders/copy', {
folder_ids: folderIds,
target_folder_id: targetFolderId
});
}
@@ -0,0 +1,130 @@
/**
* Delta upload ("upload only what changed") — ported from
* features/files/deltaUpload.js. Main-thread orchestrator for
* `/static/workers/deltaWorker.js`, which runs FastCDC chunking + BLAKE3
* (the same WASM crate/params as the server) off the UI thread, negotiates
* which chunks the server already has, uploads only the missing ones, and
* commits. Any failure resolves `null` so the caller falls back to a plain
* byte upload — delta is an optimization, never a gate.
*/
import { getCsrfToken } from '$lib/api/csrf';
/** Files smaller than this skip delta: the round-trips cost more than the bytes. */
export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024;
const DELTA_WORKER_URL = '/workers/deltaWorker.js';
const DELTA_TIMEOUT_BASE_MS = 120_000;
const DELTA_TIMEOUT_PER_GB_MS = 90_000;
export interface DeltaUploadAnswer {
ok: boolean;
data?: unknown;
errorMsg?: string;
isQuotaError?: boolean;
/** Bytes NOT transferred thanks to dedup. */
savedBytes?: number;
}
/** `false` once the environment proved unable to run the worker/WASM. */
let usable: boolean | null = null;
interface ProgressMsg {
type: 'progress';
reusedBytes: number;
uploadedBytes: number;
totalBytes: number;
}
interface FallbackMsg {
type: 'fallback';
reason?: string;
}
interface DoneMsg {
type: 'done';
status: number;
body?: { message?: string; error?: string; still_missing?: unknown };
}
type WorkerMsg = ProgressMsg | FallbackMsg | DoneMsg;
/**
* Try to upload `file` through the delta protocol. Resolves `null` whenever
* the plain byte upload should proceed (too small, environment unusable, any
* transport/protocol failure). `onProgress` receives 0–99 while transferring.
*/
export function tryDeltaUpload(
file: File,
folderId: string | null | undefined,
onProgress?: (pct: number) => void
): Promise<DeltaUploadAnswer | null> {
if (
!folderId ||
file.size < DELTA_UPLOAD_MIN_SIZE ||
usable === false ||
typeof Worker === 'undefined'
) {
return Promise.resolve(null);
}
return new Promise((resolve) => {
let worker: Worker;
try {
worker = new Worker(DELTA_WORKER_URL, { type: 'module' });
} catch {
usable = false;
resolve(null);
return;
}
const sizeGB = file.size / (1024 * 1024 * 1024);
const timeoutMs = DELTA_TIMEOUT_BASE_MS + Math.ceil(sizeGB) * DELTA_TIMEOUT_PER_GB_MS;
let savedBytes = 0;
const settle = (answer: DeltaUploadAnswer | null) => {
clearTimeout(timer);
worker.terminate();
resolve(answer);
};
const timer = setTimeout(() => settle(null), timeoutMs);
worker.onmessage = (event: MessageEvent<WorkerMsg>) => {
const msg = event.data;
if (msg.type === 'progress') {
savedBytes = msg.reusedBytes;
if (onProgress && msg.totalBytes > 0) {
const pct = Math.min(
99,
Math.round((100 * (msg.reusedBytes + msg.uploadedBytes)) / msg.totalBytes)
);
onProgress(pct);
}
return;
}
if (msg.type === 'fallback') {
settle(null);
return;
}
if (msg.type === 'done') {
if (msg.status === 201 || msg.status === 200) {
settle({ ok: true, data: msg.body, savedBytes });
return;
}
const errorMsg =
msg.body?.message || msg.body?.error || `Delta upload failed (HTTP ${msg.status})`;
if (msg.status === 507) {
settle({ ok: false, isQuotaError: true, errorMsg });
return;
}
if (msg.status === 409 && !msg.body?.still_missing) {
settle({ ok: false, errorMsg });
return;
}
settle(null);
}
};
worker.onerror = () => {
usable = false;
settle(null);
};
worker.postMessage({ file, folderId, name: file.name, csrfToken: getCsrfToken() || '' });
});
}
+46
View File
@@ -0,0 +1,46 @@
/** Device-authorization (RFC 8628) verification endpoints. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
export interface DeviceInfo {
client_name?: string;
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.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> {
const res = await apiFetch('/api/auth/device/verify', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ user_code: userCode, action })
});
if (!res.ok) throw new Error(`device ${action} failed: ${res.status}`);
}
+141
View File
@@ -0,0 +1,141 @@
/** 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,
type ResourcePage,
type ResourcePageOpts
} 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.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>> {
return fetchResourcePage<FavoritesResourceItem>('/api/favorites/resources', 'name', opts);
}
export async function addFavorite(type: ItemType, id: string): Promise<void> {
const res = await apiFetch(`/api/favorites/${type}/${id}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`add favorite failed: ${res.status}`);
}
export async function removeFavorite(type: ItemType, id: string): Promise<void> {
const res = await apiFetch(`/api/favorites/${type}/${id}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`remove favorite failed: ${res.status}`);
}
+94
View File
@@ -0,0 +1,94 @@
/** File endpoints — ported from fileOperations.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export async function uploadFile(folderId: string | null, file: File): Promise<void> {
const form = new FormData();
if (folderId) form.append('folder_id', folderId);
form.append('file', file);
const res = await apiFetch('/api/files/upload', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
headers: getCsrfHeaders(), // multipart boundary set automatically; do not set Content-Type
body: form
});
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',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename file failed: ${res.status}`);
}
export async function moveFile(fileId: string, targetFolderId: string | null): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}/move`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ folder_id: targetFolderId || null })
});
if (!res.ok) throw new Error(`move file failed: ${res.status}`);
}
export async function deleteFile(fileId: string): Promise<void> {
const res = await apiFetch(`/api/files/${fileId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete file failed: ${res.status}`);
}
export function fileDownloadUrl(fileId: string): string {
return `/api/files/${fileId}`;
}
export function fileInlineUrl(fileId: string): string {
return `/api/files/${fileId}?inline=true`;
}
/** Thumbnail URL for a file at the given size (server-rendered, content-typed). */
export function fileThumbnailUrl(
fileId: string,
size: 'icon' | 'preview' | 'large' = 'preview'
): string {
return `/api/files/${fileId}/thumbnail/${size}`;
}
+89
View File
@@ -0,0 +1,89 @@
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { FileItem, FolderItem } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const NO_CACHE: RequestInit = {
credentials: 'same-origin',
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }
};
export interface FolderListing {
folders: FolderItem[];
files: FileItem[];
}
/** Top-level folders for the user; the first entry is the home folder. */
export function listRootFolders(): Promise<FolderItem[]> {
return apiJson<FolderItem[]>('/api/folders', { credentials: 'same-origin' });
}
export function getFolder(id: string): Promise<FolderItem> {
return apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
}
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
const ts = Math.floor(Date.now() / 1000);
let url = `/api/folders/${folderId}/listing?t=${ts}`;
const headers: Record<string, string> = {
'Cache-Control': 'no-cache, no-store, must-revalidate'
};
if (forceRefresh) {
url += '&force_refresh=true';
headers['X-Force-Refresh'] = 'true';
}
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
const listing = (await res.json()) as Partial<FolderListing>;
return {
folders: Array.isArray(listing.folders) ? listing.folders : [],
files: Array.isArray(listing.files) ? listing.files : []
};
}
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
const res = await apiFetch('/api/folders', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name, parent_id: parentId })
});
if (!res.ok) throw new Error(`create folder failed: ${res.status}`);
return (await res.json()) as FolderItem;
}
export async function renameFolder(folderId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename folder failed: ${res.status}`);
}
export async function moveFolder(folderId: string, targetFolderId: string | null): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}/move`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ parent_id: targetFolderId || null })
});
if (!res.ok) throw new Error(`move folder failed: ${res.status}`);
}
export async function deleteFolder(folderId: string): Promise<void> {
const res = await apiFetch(`/api/folders/${folderId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete folder failed: ${res.status}`);
}
export function folderZipUrl(folderId: string): string {
return `/api/folders/${folderId}/download?format=zip`;
}
+216
View File
@@ -0,0 +1,216 @@
/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */
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';
/** Roles the share UI exposes. The backend role enum also has `commenter` and
* `contributor`, which {@link displayRole} collapses to the nearest of these. */
export type ShareRole = 'viewer' | 'editor' | 'owner';
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 role grant for a (subject, resource). Role-keyed since the role-grants
* migration: each row carries an explicit `role` (the backend enum, which may
* be `owner`/`editor`/`viewer`/`commenter`/`contributor`).
*/
export interface Grant {
id: string;
granted_at?: string;
granted_by?: string;
subject: GrantSubject;
role: 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;
}
/**
* Map a backend role string to the role the UI exposes. The server may emit the
* full enum (`owner`/`editor`/`viewer`/`commenter`/`contributor`); the picker
* only shows Owner/Editor/Viewer, so collapse the two unexposed roles to their
* closest neighbour rather than render an unknown option.
*/
export function displayRole(role: string | undefined): ShareRole {
if (role === 'owner' || role === 'editor' || role === 'viewer') return role;
if (role === 'contributor') return 'editor';
if (role === 'commenter') return 'viewer';
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;
granted_by?: string;
granted_at?: string;
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;
first_shared_at?: string;
/** One entry per (subject, permissions) pair. */
grants: OutgoingResourceGrant[];
}
interface GrantsPageOpts {
cursor?: string;
orderBy?: string;
limit?: number;
reverse?: boolean;
resourceTypes?: ItemType[];
}
function params(opts: GrantsPageOpts): string {
const { cursor, orderBy, limit = 50, reverse = false, resourceTypes } = opts;
const p = new URLSearchParams({ limit: String(limit) });
if (resourceTypes?.length) p.set('resource_types', resourceTypes.join(','));
if (cursor) p.set('cursor', cursor);
if (orderBy) p.set('sort_by', orderBy);
if (reverse) p.set('reverse', 'true');
return p.toString();
}
export async function fetchSharedWithMe(
opts: GrantsPageOpts = {}
): Promise<ResourcePage<IncomingGrantItem>> {
const res = await apiFetch(
`/api/grants/incoming/resources?${params({ resourceTypes: ['file', 'folder'], ...opts })}`,
{ credentials: 'same-origin' }
);
if (!res.ok) throw new Error(`shared-with-me failed: ${res.status}`);
return (await res.json()) as ResourcePage<IncomingGrantItem>;
}
export async function fetchMyShares(
opts: GrantsPageOpts = {}
): Promise<ResourcePage<OutgoingGrantItem>> {
const res = await apiFetch(`/api/grants/outgoing/resources?${params(opts)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`my-shares failed: ${res.status}`);
return (await res.json()) as ResourcePage<OutgoingGrantItem>;
}
+130
View File
@@ -0,0 +1,130 @@
/** 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 {
kind: 'user' | 'group';
id: string;
}
async function mutate(url: string, method: string, body?: unknown): Promise<void> {
const res = await apiFetch(url, {
method,
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!res.ok) throw new Error(`${method} ${url} failed: ${res.status}`);
}
/** 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[]; 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> {
return mutate('/api/groups', 'POST', { name, description: description ?? null });
}
export function renameGroup(id: string, name: string): Promise<void> {
return mutate(`/api/groups/${enc(id)}`, 'PATCH', { name });
}
export function deleteGroup(id: string): Promise<void> {
return mutate(`/api/groups/${enc(id)}`, 'DELETE');
}
export function listMembers(id: string): Promise<GroupMember[]> {
return apiJson<GroupMember[]>(`/api/groups/${enc(id)}/members`, { credentials: 'same-origin' });
}
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');
}
export function removeGroupMember(groupId: string, memberGroupId: string): Promise<void> {
return mutate(`/api/groups/${enc(groupId)}/members/group/${enc(memberGroupId)}`, 'DELETE');
}
+168
View File
@@ -0,0 +1,168 @@
/** Music / playlist endpoints — ported from features/library/music.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export interface Playlist {
id: string;
name: string;
description: string | null;
owner_id: string;
is_public: boolean;
cover_file_id: string | null;
track_count: number;
total_duration_secs: number;
created_at: number;
updated_at: number;
}
export interface PlaylistItem {
id: string;
playlist_id: string;
file_id: string;
position: number;
added_at: number;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
title: string | null;
artist: string | null;
album: string | null;
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' });
}
export function listTracks(playlistId: string): Promise<PlaylistItem[]> {
return apiJson<PlaylistItem[]>(`/api/playlists/${playlistId}/tracks`, {
credentials: 'same-origin'
});
}
export async function createPlaylist(name: string): Promise<Playlist> {
const res = await apiFetch('/api/playlists', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name, description: null })
});
if (!res.ok) throw new Error(`create playlist failed: ${res.status}`);
return (await res.json()) as Playlist;
}
/** 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(patch)
});
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> {
const res = await apiFetch(`/api/playlists/${playlistId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`delete playlist failed: ${res.status}`);
}
export async function addTracks(playlistId: string, fileIds: string[]): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/tracks`, {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ file_ids: fileIds })
});
if (!res.ok) throw new Error(`add tracks failed: ${res.status}`);
}
export async function removeTrack(playlistId: string, fileId: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/tracks/${encodeURIComponent(fileId)}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`remove track failed: ${res.status}`);
}
/** Persist a new track order. `itemIds` are PlaylistItem ids in the desired order. */
export async function reorderTracks(playlistId: string, itemIds: string[]): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}/reorder`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ item_ids: itemIds })
});
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;
}
+55
View File
@@ -0,0 +1,55 @@
/** People (faces) endpoints — ported from features/library/people.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
/** An identity cluster from `GET /api/people`. */
export interface Person {
id: string;
/** Absent until the user names the person. */
name?: string;
/** File id of the cover face's photo, for the tile thumbnail. */
cover_file_id?: string;
face_count: number;
is_hidden: boolean;
}
/**
* List identity clusters. The feature is gated on `OXICLOUD_ENABLE_FACES` —
* when it is off the route 404s; callers treat that as "faces disabled".
*/
export async function fetchPeople(): Promise<Person[]> {
const res = await apiFetch('/api/people', { credentials: 'same-origin' });
if (!res.ok) throw new Error(`people failed: ${res.status}`);
return (await res.json()) as Person[];
}
/**
* Probe whether the People feature is available (faces enabled). Used to reveal
* the People tab only when the backend can serve it.
*/
export async function peopleEnabled(): Promise<boolean> {
try {
const res = await apiFetch('/api/people', { credentials: 'same-origin' });
return res.ok;
} catch {
return false;
}
}
/** File ids of the photos a person appears in. */
export async function fetchPersonPhotos(personId: string): Promise<string[]> {
const res = await apiFetch(`/api/people/${personId}/photos`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`person photos failed: ${res.status}`);
return (await res.json()) as string[];
}
/** Rename a person, or pass `null` to clear the name. */
export async function renamePerson(personId: string, name: string | null): Promise<void> {
const res = await apiFetch(`/api/people/${personId}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename failed: ${res.status}`);
}
+134
View File
@@ -0,0 +1,134 @@
/** 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';
/**
* A timeline photo/video. Extends {@link FileItem} with the pixel dimensions the
* list endpoint returns, used by the justified (aspect-preserving) grid layout.
*/
export interface PhotoItem extends FileItem {
width?: number;
height?: number;
}
export interface PhotoPage {
items: PhotoItem[];
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[];
}
/** One server-side photo cluster for the Places map (`GET /api/photos/geo`). */
export interface GeoCluster {
lng: number;
lat: number;
count: number;
sample_file_id: string;
}
/**
* Fetch geotagged-photo clusters for a viewport. The backend aggregates
* server-side on a grid keyed by zoom, so the client draws one lightweight
* marker per cluster — no client-side clustering needed. `bbox` is
* `"west,south,east,north"` in decimal degrees. Available only when the
* Places feature is enabled (otherwise the route 404s).
*/
export async function fetchPhotosGeo(bbox: string, zoom: number): Promise<GeoCluster[]> {
const res = await apiFetch(`/api/photos/geo?bbox=${encodeURIComponent(bbox)}&zoom=${zoom}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`photos geo failed: ${res.status}`);
return (await res.json()) as GeoCluster[];
}
/** 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
* `limit` items come back.
*/
export async function fetchPhotos(limit = 60, before?: string | null): Promise<PhotoPage> {
let url = `/api/photos?limit=${limit}`;
if (before) url += `&before=${encodeURIComponent(before)}`;
const res = await apiFetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`photos failed: ${res.status}`);
const items = (await res.json()) as PhotoItem[];
const cursor = res.headers.get('X-Next-Cursor');
return {
items: items ?? [],
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
});
}
+123
View File
@@ -0,0 +1,123 @@
/** Profile / account endpoints — ported from views/profile/profile.js. */
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' };
export interface ProfilePatch {
username?: string;
given_name?: string;
family_name?: string;
preferred_locale?: string;
notify_on_share?: boolean;
}
export async function updateProfile(patch: ProfilePatch): Promise<User> {
const res = await apiFetch('/api/auth/me/profile', {
method: 'PATCH',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(patch)
});
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;
}
export async function changePassword(currentPw: string, newPw: string): Promise<void> {
const res = await apiFetch('/api/auth/change-password', {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ current_password: currentPw, new_password: newPw })
});
if (!res.ok) throw new Error(`password change failed: ${res.status}`);
}
export async function updateAvatar(image: string | null): Promise<void> {
const res = await apiFetch('/api/auth/me/image', {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ image })
});
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}`);
}
+32
View File
@@ -0,0 +1,32 @@
/** Recent endpoints — ported from recentModel.js. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import {
fetchResourcePage,
type ResourceBody,
type ResourcePage,
type ResourcePageOpts
} from './resources';
import type { ItemType } from '$lib/api/types';
export interface RecentResourceItem {
resource_type: ItemType;
accessed_at: string;
resource: ResourceBody;
}
export function fetchRecentPage(
opts?: ResourcePageOpts
): Promise<ResourcePage<RecentResourceItem>> {
return fetchResourcePage<RecentResourceItem>('/api/recent/resources', 'accessed_at', opts);
}
export async function clearRecent(): Promise<void> {
const res = await apiFetch('/api/recent/clear', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`clear recent 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);
}
@@ -0,0 +1,42 @@
/**
* Shared cursor-pagination helper for the favorites/recent/trash "resources"
* endpoints, which all take the same query params. Ported from the original
* favoritesModel/recentModel/trashModel.
*/
import { apiFetch } from '$lib/api/client';
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
export interface ResourcePageOpts {
cursor?: string;
orderBy?: string;
limit?: number;
reverse?: boolean;
resourceTypes?: ItemType[];
}
export interface ResourcePage<TItem> {
items: TItem[];
next_cursor?: string;
}
export type ResourceBody = FileItem | FolderItem;
export function buildResourceParams(opts: ResourcePageOpts, defaultOrderBy: string): string {
const { cursor, orderBy = defaultOrderBy, limit = 50, reverse = false, resourceTypes } = opts;
const params = new URLSearchParams({ order_by: orderBy, limit: String(limit) });
if (cursor) params.set('cursor', cursor);
if (reverse) params.set('reverse', 'true');
if (resourceTypes?.length) params.set('resource_types', resourceTypes.join(','));
return params.toString();
}
export async function fetchResourcePage<TItem>(
base: string,
defaultOrderBy: string,
opts: ResourcePageOpts = {}
): Promise<ResourcePage<TItem>> {
const qs = buildResourceParams(opts, defaultOrderBy);
const res = await apiFetch(`${base}?${qs}`, { credentials: 'same-origin', cache: 'no-store' });
if (!res.ok) throw new Error(`GET ${base} failed: ${res.status}`);
return (await res.json()) as ResourcePage<TItem>;
}
+77
View File
@@ -0,0 +1,77 @@
/** Search endpoint — ported from features/files/search.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import type { SearchResults, SortBy } from '$lib/api/types';
export interface SearchOptions {
folderId?: string;
recursive?: boolean;
fileTypes?: string[];
minSize?: number;
maxSize?: number;
/** Unix-seconds lower bound on created time. */
createdAfter?: number;
/** Unix-seconds upper bound on created time. */
createdBefore?: number;
/** Unix-seconds lower bound on modified time. */
modifiedAfter?: number;
/** Unix-seconds upper bound on modified time. */
modifiedBefore?: number;
limit?: number;
offset?: number;
sortBy?: SortBy;
}
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
const params = new URLSearchParams();
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.recursive !== undefined) params.append('recursive', String(opts.recursive));
for (const ft of opts.fileTypes ?? []) params.append('type', ft);
if (opts.minSize != null) params.append('min_size', String(opts.minSize));
if (opts.maxSize != null) params.append('max_size', String(opts.maxSize));
if (opts.createdAfter != null) params.append('created_after', String(opts.createdAfter));
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
params.append('limit', String(opts.limit ?? 100));
params.append('offset', String(opts.offset ?? 0));
params.append('sort_by', opts.sortBy ?? 'relevance');
return apiJson<SearchResults>(`/api/search?${params.toString()}`, { credentials: 'same-origin' });
}
/** A single autocomplete suggestion returned by the lightweight suggest endpoint. */
export interface SearchSuggestions {
suggestions: string[];
query_time_ms: number;
}
export interface SuggestOptions {
folderId?: string;
limit?: number;
}
/**
* Lightweight autocomplete suggestions from the backend `GET /api/search/suggest`
* endpoint — name-only hints without the full search overhead.
*/
export function searchSuggest(
query: string,
opts: SuggestOptions = {}
): Promise<SearchSuggestions> {
const params = new URLSearchParams();
params.append('query', query);
if (opts.folderId) params.append('folder_id', opts.folderId);
if (opts.limit != null) params.append('limit', String(opts.limit));
return apiJson<SearchSuggestions>(`/api/search/suggest?${params.toString()}`, {
credentials: 'same-origin'
});
}
/** Clear the server-side search cache (`DELETE /api/search/cache`). */
export async function clearSearchCache(): Promise<void> {
const res = await apiFetch('/api/search/cache', {
method: 'DELETE',
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`Failed to clear search cache: ${res.status} ${res.statusText}`);
}
+94
View File
@@ -0,0 +1,94 @@
/**
* Public share endpoints (/api/s/{token}). These intentionally run through
* apiFetch, which bypasses the refresh-and-retry path for /api/s/ — a 401 here
* means "password required", not "session expired".
*/
import { apiFetch } from '$lib/api/client';
import type { ItemType } from '$lib/api/types';
export interface ShareMeta {
item_type: ItemType;
item_name: string;
}
export interface ShareFolderEntry {
id: string;
name: string;
}
export interface ShareFileEntry {
id: string;
name: string;
mime_type?: string;
size?: number;
}
export interface ShareListing {
folders: ShareFolderEntry[];
files: ShareFileEntry[];
}
export type ShareMetaResult =
| { status: 'ok'; data: ShareMeta }
| { status: 'password' }
| { status: 'expired' }
| { status: 'invalid' };
const enc = encodeURIComponent;
export async function getShareMeta(token: string): Promise<ShareMetaResult> {
const res = await apiFetch(`/api/s/${enc(token)}`);
if (res.ok) return { status: 'ok', data: (await res.json()) as ShareMeta };
if (res.status === 401) {
const body = (await res.json().catch(() => null)) as { requiresPassword?: boolean } | null;
if (body?.requiresPassword) return { status: 'password' };
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}`);
}
/** Returns true on success, false on incorrect password. */
export async function verifySharePassword(token: string, password: string): Promise<boolean> {
const res = await apiFetch(`/api/s/${enc(token)}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (res.ok) return true;
if (res.status === 401) return false;
throw new Error(`HTTP ${res.status}`);
}
export type ShareListingResult =
| { status: 'ok'; data: ShareListing }
| { status: 'password' }
| { status: 'expired' };
export async function getShareContents(
token: string,
folderId?: string
): Promise<ShareListingResult> {
const url = folderId
? `/api/s/${enc(token)}/contents/${enc(folderId)}`
: `/api/s/${enc(token)}/contents`;
const res = await apiFetch(url);
if (res.ok) return { status: 'ok', data: (await res.json()) as ShareListing };
if (res.status === 401) return { status: 'password' };
if (res.status === 410 || res.status === 404) return { status: 'expired' };
throw new Error(`HTTP ${res.status}`);
}
export function shareDownloadUrl(token: string): string {
return `/api/s/${enc(token)}/download`;
}
export function shareFileUrl(token: string, fileId: string): string {
return `/api/s/${enc(token)}/file/${enc(fileId)}`;
}
export function shareZipUrl(token: string, folderId?: string): string {
return folderId ? `/api/s/${enc(token)}/zip/${enc(folderId)}` : `/api/s/${enc(token)}/zip`;
}
+110
View File
@@ -0,0 +1,110 @@
/** Public share-link endpoints (/api/shares) — ported from features/sharing. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
import type { ItemType, ShareItem } from '$lib/api/types';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
export interface CreateShareInput {
itemId: string;
/** Optional human-readable link name (stored as `item_name`). */
itemName?: string | null;
itemType: ItemType;
password?: string | null;
/** ISO date string or null; converted to epoch seconds for the wire. */
expiresAt?: string | null;
}
export async function createShare(input: CreateShareInput): Promise<ShareItem> {
const body = {
item_id: input.itemId,
item_name: input.itemName ?? null,
item_type: input.itemType,
password: input.password || null,
expires_at: input.expiresAt ? Math.floor(new Date(input.expiresAt).getTime() / 1000) : null
};
const res = await apiFetch('/api/shares', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(e.error || `create share failed: ${res.status}`);
}
return (await res.json()) as ShareItem;
}
export async function listSharesForItem(itemId: string, itemType: ItemType): Promise<ShareItem[]> {
const params = new URLSearchParams({ item_id: itemId, item_type: itemType });
const res = await apiFetch(`/api/shares?${params}`, { credentials: 'same-origin' });
if (!res.ok) return [];
const data = (await res.json()) as ShareItem[] | { items?: ShareItem[] };
return Array.isArray(data) ? data : (data.items ?? []);
}
/** Fetch a single share by its UUID (used to resolve a token's URL on demand). */
export async function getShareById(shareId: string): Promise<ShareItem> {
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`get share failed: ${res.status}`);
return (await res.json()) as ShareItem;
}
export interface UpdateShareInput {
/** `null` clears the password; omit to leave it unchanged. */
password?: string | null;
/** ISO date string clears/sets; converted to epoch seconds. `null` clears. */
expiresAt?: string | null;
}
/**
* Edit an existing public link's password and/or expiry.
* `PUT /api/shares/{id}` with `{ password, expires_at }`.
*/
export async function updateShare(shareId: string, input: UpdateShareInput): Promise<ShareItem> {
const body: { password?: string | null; expires_at?: number | null } = {};
if (input.password !== undefined) body.password = input.password;
if (input.expiresAt !== undefined) {
body.expires_at = input.expiresAt
? Math.floor(new Date(input.expiresAt).getTime() / 1000)
: null;
}
const res = await apiFetch(`/api/shares/${encodeURIComponent(shareId)}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify(body)
});
if (!res.ok) {
const e = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(e.error || `update share failed: ${res.status}`);
}
return (await res.json()) as ShareItem;
}
export async function deleteShare(shareId: string): Promise<void> {
const res = await apiFetch(`/api/shares/${shareId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok && res.status !== 204) throw new Error(`delete share failed: ${res.status}`);
}
/**
* Copy a share URL to the clipboard, resolving it against the current origin.
* Shared by the dialog and My Shares so copy-link logic lives in one place.
* Returns `true` on success.
*/
export async function copyShareLink(url: string): Promise<boolean> {
try {
const absolute = typeof location !== 'undefined' ? new URL(url, location.origin).href : url;
await navigator.clipboard.writeText(absolute);
return true;
} catch {
return false;
}
}
+113
View File
@@ -0,0 +1,113 @@
/** 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';
export function fetchTrashPage(opts?: ResourcePageOpts): Promise<ResourcePage<TrashResourceItem>> {
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',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: '{}'
});
if (!res.ok) throw new Error(`restore failed: ${res.status}`);
}
export async function deleteTrashItem(trashId: string): Promise<void> {
const res = await apiFetch(`/api/trash/${trashId}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`permanent delete failed: ${res.status}`);
}
export async function emptyTrash(): Promise<void> {
const res = await apiFetch('/api/trash/empty', {
method: 'DELETE',
credentials: 'same-origin',
headers: getCsrfHeaders()
});
if (!res.ok) throw new Error(`empty trash failed: ${res.status}`);
}
+85
View File
@@ -0,0 +1,85 @@
/**
* WOPI (Collabora / OnlyOffice) integration — ported from features/files/wopiEditor.js.
* `getEditorUrl` returns the iframe action URL + access token; the office editor
* is launched by POST-ing the token to that URL (see WopiEditor.svelte).
*/
import { apiFetch } from '$lib/api/client';
export interface WopiEditorData {
editor_url: string;
access_token: string;
access_token_ttl: string | number;
}
const FALLBACK_EXTS = [
'docx',
'doc',
'odt',
'rtf',
'txt',
'xlsx',
'xls',
'ods',
'csv',
'pptx',
'ppt',
'odp'
];
let cachedExts: string[] | null = null;
export async function getSupportedExtensions(): Promise<string[]> {
if (cachedExts) return cachedExts;
try {
const res = await fetch('/wopi/supported-extensions');
if (res.ok) {
const exts = (await res.json()) as string[];
if (Array.isArray(exts) && exts.length > 0) {
cachedExts = exts;
return exts;
}
}
} catch {
/* fall through to the hardcoded list */
}
cachedExts = FALLBACK_EXTS;
return cachedExts;
}
export async function canEditWithWopi(filename: string): Promise<boolean> {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
return (await getSupportedExtensions()).includes(ext);
}
export async function getEditorUrl(
fileId: string,
action: 'edit' | 'view' = 'edit'
): Promise<WopiEditorData> {
const res = await apiFetch(
`/api/wopi/editor-url?file_id=${encodeURIComponent(fileId)}&action=${encodeURIComponent(action)}`,
{ credentials: 'same-origin' }
);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Editor URL request failed: ${res.status} ${text}`);
}
return (await res.json()) as WopiEditorData;
}
/** PDFs are view-only in WOPI: an edit request returns 422 → retry as view. */
export async function getEditorUrlWithFallback(
fileId: string,
filename: string,
action: 'edit' | 'view' = 'edit'
): Promise<WopiEditorData> {
try {
return await getEditorUrl(fileId, action);
} catch (e) {
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
const msg = e instanceof Error ? e.message : '';
if (action === 'edit' && ext === 'pdf' && msg.includes('422')) {
return getEditorUrl(fileId, 'view');
}
throw e;
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* API wire types — ported from static/js/core/types.js.
*
* This is a focused, hand-ported subset covering the core resources. The plan
* is to regenerate the full set from the backend OpenAPI (`just openapi` +
* `openapi-typescript`) so these track the Rust DTOs; until then, extend here.
*/
export type ItemType = 'file' | 'folder';
export interface LightItem {
id: string;
name: string;
type: ItemType;
parentId: string;
}
export interface FolderItem {
category: string;
created_at: number;
icon_class: string;
icon_special_class: string;
id: string;
is_root: boolean;
modified_at: number;
name: string;
owner_id: string;
parent_id: string | null;
path: string;
etag: string;
}
export interface FileItem {
category: string;
created_at: number;
icon_class: string;
icon_special_class: string;
id: string;
mime_type: string;
modified_at: number;
name: string;
owner_id: string;
folder_id: string;
path: string;
size: number;
size_formatted: string;
sort_date: number;
etag: string;
content_hash: string;
/** Search-only: plain-text fragment around a content match. */
snippet?: string;
/** Search-only: "name" or "content". */
match_source?: string;
}
export interface ShareItem {
access_count: number;
created_at: number;
created_by: string;
expires_at: number;
has_password: boolean;
id: string;
item_id: string;
item_name: string;
item_type: ItemType;
token: string | null;
url: string;
}
export interface CreateShare {
item_id: string;
item_name?: string | null;
item_type: ItemType;
password: string | null;
expires_at: number | null;
}
export interface UpdateShare {
password?: string | null;
expires_at?: number | null;
}
export interface FavoriteItem {
id: string;
user_id: string;
item_id: string;
item_type: ItemType;
created_at: number;
item_name: string | null;
item_size: number | null;
item_mime_type: string | null;
parent_id: string | null;
modified_at: number | null;
item_path: string;
icon_class: string;
icon_special_class: string;
category: string;
size_formatted: string;
owner_id: string | null;
}
export interface RecentItem {
id: string;
user_id: string;
item_id: string;
item_type: ItemType;
accessed_at: number;
item_name: string | null;
item_size: number | null;
item_mime_type: string | null;
parent_id: string | null;
item_path: string;
icon_class: string;
icon_special_class: string;
category: string;
size_formatted: string;
}
export interface TrashResourceItem {
resource_type: ItemType;
trashed_at: string;
deletion_date: string;
resource: FileItem | FolderItem;
}
export interface TrashResourcesResponse {
items: TrashResourceItem[];
next_cursor?: string;
}
export type Role = 'user' | 'admin';
/** Wire shape of `UserDto` (backend: src/application/dtos/user_dto.rs). */
export interface User {
id: string;
username?: string;
email: string;
role: string;
storage_quota_bytes: number;
storage_used_bytes: number;
created_at: string;
updated_at: string;
last_login_at?: string | null;
active: boolean;
auth_provider: string;
image?: string | null;
can_edit_image: boolean;
is_external: boolean;
given_name?: string;
family_name?: string;
email_verified_at?: string;
preferred_locale?: string;
notify_on_share: boolean;
}
export interface AuthResponse {
user: User;
access_token: string;
refresh_token: string;
token_type: string;
expires_in: number;
}
export type SortBy =
| 'relevance'
| 'name'
| 'name_desc'
| 'date'
| 'date_desc'
| 'size'
| 'size_desc';
export interface SearchCriteria {
sort_by: SortBy;
recursive: boolean;
limit: number;
offset: number;
name_contains?: string;
file_types?: string[];
folder_id?: string;
min_size?: number;
max_size?: number;
created_before?: number;
created_after?: number;
modified_before?: number;
modified_after?: number;
}
export interface SearchResults {
files: FileItem[];
folders: FolderItem[];
total_count: number | null;
limit: number;
offset: number;
has_more: boolean;
query_time_ms: number;
sort_by: string;
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
type Variant = 'primary' | 'secondary' | 'danger';
interface Props {
/** Visual style → `.btn-{variant}` (default `secondary`). */
variant?: Variant;
/** Optional leading icon-registry name. */
icon?: string;
/** Compact size → adds `.btn-sm`. */
small?: boolean;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
title?: string;
onclick?: (e: MouseEvent) => void;
/** Extra classes appended after the base `.btn` classes. */
class?: string;
children?: Snippet;
}
let {
variant = 'secondary',
icon,
small = false,
type = 'button',
disabled = false,
title,
onclick,
class: cls = '',
children
}: Props = $props();
const className = $derived(
['btn', `btn-${variant}`, small ? 'btn-sm' : '', cls].filter(Boolean).join(' ')
);
</script>
<button class={className} {type} {disabled} {title} {onclick}>
{#if icon}<Icon name={icon} />{/if}
{@render children?.()}
</button>
@@ -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>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
interface Props {
/** Icon-registry name shown above the title (omit for a text-only state). */
icon?: string;
/** Primary line. */
title?: string;
/** Secondary explanatory line. */
hint?: string;
/** Error styling (danger-coloured icon) + assertive `role="alert"`. */
error?: boolean;
/** Extra content (e.g. a call-to-action button) rendered below the hint. */
children?: Snippet;
}
let { icon, title, hint, error = false, children }: Props = $props();
</script>
<div class="empty-state" class:empty-state--error={error} role={error ? 'alert' : undefined}>
{#if icon}<Icon name={icon} class="empty-state__icon" />{/if}
{#if title}<p class="empty-state__title">{title}</p>{/if}
{#if hint}<p class="empty-state__hint">{hint}</p>{/if}
{@render children?.()}
</div>
<style>
/* Layout comes from the global `.empty-state` (styles/ported/content.css);
these refine the icon/title/hint elements consistently across views. */
.empty-state :global(.empty-state__icon) {
font-size: var(--text-5xl);
color: var(--color-text-faint);
margin-bottom: var(--space-2);
}
.empty-state--error :global(.empty-state__icon) {
color: var(--color-danger-text);
}
.empty-state__title {
margin: 0;
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
}
.empty-state__hint {
margin: 0;
max-width: 28rem;
font-size: var(--text-sm);
color: var(--color-text-muted);
}
</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>
+199
View File
@@ -0,0 +1,199 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
open: boolean;
title?: string;
/** Called when the user requests close (backdrop click, Escape, ✕). */
onclose?: () => void;
children?: Snippet;
footer?: Snippet;
}
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?.();
}
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} />
{#if open}
<!-- backdrop -->
<div
class="modal__backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) close();
}}
>
<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>
<button class="modal__close" aria-label={t('common.close', 'Close')} onclick={close}
>×</button
>
</header>
{/if}
<div class="modal__body">
{@render children?.()}
</div>
{#if footer}
<footer class="modal__footer">
{@render footer()}
</footer>
{/if}
</div>
</div>
{/if}
<style>
.modal__backdrop {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 900;
padding: 1rem;
animation: modal-fade 0.16s ease;
}
.modal {
background: var(--color-bg-surface);
color: var(--color-text);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
width: min(92vw, 32rem);
max-height: 90vh;
overflow: auto;
display: flex;
flex-direction: column;
animation: modal-pop 0.18s ease;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.modal__title {
margin: 0;
font-size: 1.125rem;
}
.modal__close {
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--color-text-muted);
}
.modal__body {
padding: 1.25rem;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
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,274 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
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) {
errorToast(e);
} 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) {
errorToast(e);
} 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,297 @@
<script lang="ts">
/**
* People (faces): a grid of identity clusters from `GET /api/people`; clicking
* a person shows their photos in the shared lightbox. Faces are detected and
* clustered server-side, so this view is read-mostly (list, drill-in, rename).
* Gated on `OXICLOUD_ENABLE_FACES` — when off the API 404s and we show a hint.
*/
import EmptyState from '$lib/components/EmptyState.svelte';
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import Icon from '$lib/icons/Icon.svelte';
import {
fetchPeople,
fetchPersonPhotos,
renamePerson,
type Person
} from '$lib/api/endpoints/people';
import { fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { errorMessage } from '$lib/utils/errors';
import { minimalPhotoItem } from '$lib/utils/media';
import { onMount } from 'svelte';
type View = 'list' | 'person';
let view = $state<View>('list');
let people = $state<Person[]>([]);
let loading = $state(true);
/** Set when the feature is unavailable (faces disabled) or the list errors. */
let disabled = $state(false);
// Drill-in state.
let current = $state<{ id: string; name: string } | null>(null);
let photos = $state<FileItem[]>([]);
let lightbox = $state(-1);
function personName(p: Person): string {
return p.name || t('people.unnamed', 'Unnamed');
}
async function loadList() {
loading = true;
disabled = false;
try {
people = await fetchPeople();
} catch {
people = [];
disabled = true;
} finally {
loading = false;
}
}
async function openPerson(p: Person) {
current = { id: p.id, name: personName(p) };
view = 'person';
photos = [];
lightbox = -1;
try {
const ids = await fetchPersonPhotos(p.id);
photos = ids.map(minimalPhotoItem);
} catch {
photos = [];
}
}
function backToList() {
view = 'list';
current = null;
lightbox = -1;
}
async function rename() {
if (!current) return;
const placeholder = t('people.unnamed', 'Unnamed');
const value = current.name === placeholder ? '' : current.name;
const next = await promptDialog({
title: t('people.rename_title', 'Name this person'),
message: t('people.name_label', 'Name'),
defaultValue: value
});
if (next === null) return;
const trimmed = next.trim();
try {
await renamePerson(current.id, trimmed || null);
current = { id: current.id, name: trimmed || placeholder };
// Keep the list in sync so a return trip shows the new name.
people = people.map((p) => (p.id === current?.id ? { ...p, name: trimmed || undefined } : p));
} catch (e) {
// Surface the failure inline via the dialog's own error channel is not
// available here; fall back to logging — rename is non-destructive.
console.error('rename failed:', errorMessage(e));
}
}
function onDeletePhoto(id: string) {
photos = photos.filter((p) => p.id !== id);
}
onMount(loadList);
</script>
{#if loading}
<p class="people-status">{t('common.loading', 'Loading…')}</p>
{:else if disabled}
<EmptyState icon="user-group" title={t('people.disabled', 'Face recognition is disabled')} />
{:else if view === 'list'}
{#if people.length === 0}
<EmptyState icon="user-group" title={t('people.empty', 'No people yet')} />
{:else}
<ul class="people-grid">
{#each people as person (person.id)}
<li>
<button class="person-tile" type="button" onclick={() => openPerson(person)}>
<span class="person-avatar">
{#if person.cover_file_id}
<img src={fileThumbnailUrl(person.cover_file_id, 'icon')} alt="" loading="lazy" />
{:else}
<Icon name="user-group" />
{/if}
</span>
<span class="person-name">{personName(person)}</span>
<span class="person-count">{person.face_count}</span>
</button>
</li>
{/each}
</ul>
{/if}
{:else if current}
<div class="people-toolbar">
<button
class="people-back"
type="button"
aria-label={t('people.back', 'Back')}
onclick={backToList}
>
<Icon name="arrow-left" />
</button>
<h2 class="people-title">{current.name}</h2>
<button
class="people-rename"
type="button"
aria-label={t('people.rename_title', 'Name this person')}
onclick={rename}
>
<Icon name="pen" />
</button>
</div>
<ul class="photos">
{#each photos as photo, i (photo.id)}
<li class="photos__cell">
<button class="photos__open" onclick={() => (lightbox = i)}>
<img src={fileThumbnailUrl(photo.id, 'preview')} alt="" loading="lazy" decoding="async" />
</button>
</li>
{/each}
</ul>
<PhotoLightbox items={photos} bind:index={lightbox} onDelete={onDeletePhoto} />
{/if}
<style>
.people-status {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 0;
}
.people-grid {
list-style: none;
margin: 0;
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
gap: var(--space-4);
}
.person-tile {
display: flex;
flex-direction: column;
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-md);
}
.person-tile:hover {
background: var(--color-bg-hover);
}
.person-avatar {
display: grid;
place-items: center;
width: 5.5rem;
height: 5.5rem;
border-radius: 50%;
overflow: hidden;
background: var(--color-bg-muted);
color: var(--color-text-muted);
font-size: 1.5rem;
}
.person-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.person-name {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
}
.person-count {
font-size: var(--text-xs, 0.75rem);
color: var(--color-text-muted);
}
.people-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: 1rem;
}
.people-back,
.people-rename {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.people-back:hover,
.people-rename:hover {
background: var(--color-bg-hover);
}
.people-title {
flex: 1;
margin: 0;
font-size: 1.25rem;
color: var(--color-text-heading);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.photos {
list-style: none;
margin: 0;
padding: 0 1rem 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__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;
}
</style>
@@ -0,0 +1,377 @@
<script lang="ts">
/**
* Full-screen photo/video lightbox, shared by the Photos timeline, People and
* Places views. Driven by an `items` list and a bindable `index` (-1 = closed);
* deletions are reported via `onDelete` so the parent can update its own list.
*/
import Icon from '$lib/icons/Icon.svelte';
import { addFavorite } from '$lib/api/endpoints/favorites';
import {
deleteFile,
fileDownloadUrl,
fileInlineUrl,
fileThumbnailUrl
} from '$lib/api/endpoints/files';
import { fetchFileMetadata, type FileMetadata } from '$lib/api/endpoints/photos';
import type { FileItem } from '$lib/api/types';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
import { t } from '$lib/i18n/index.svelte';
import { errorToast } from '$lib/utils/errors';
import { isVideo, photoTimestamp } from '$lib/utils/media';
interface Props {
items: FileItem[];
/** Current index into `items`; -1 means closed. */
index: number;
/** Called after a successful delete so the parent can drop it from `items`. */
onDelete?: (id: string) => void;
}
let { items, index = $bindable(), onDelete }: Props = $props();
let showingOriginal = $state(false);
let fullResBusy = $state(false);
let meta = $state('');
let favorited = $state(false);
/** Token guarding against stale async loads during rapid prev/next. */
let generation = 0;
const item = $derived(index >= 0 ? (items[index] ?? null) : null);
// Clamp the index when the list shrinks under us (e.g. after a delete): drop
// to the last item, or close when nothing is left.
$effect(() => {
if (index < 0) return;
if (items.length === 0) index = -1;
else if (index >= items.length) index = items.length - 1;
});
function baseMeta(p: FileItem): string {
const dateStr = new Date(photoTimestamp(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}`);
meta = parts.join(' · ');
}
/** Reset per-item state and kick off metadata + neighbour preload. */
function showItem(p: FileItem) {
const gen = ++generation;
showingOriginal = p.mime_type === 'image/gif';
fullResBusy = false;
favorited = false;
meta = baseMeta(p);
preloadNeighbors();
void fetchFileMetadata(p.id).then((md) => {
if (md && gen === generation) applyMetadata(p, md);
});
}
// Re-run per-item setup whenever the visible item changes.
$effect(() => {
if (item) showItem(item);
});
function preloadNeighbors() {
for (const i of [index - 1, index + 1]) {
const it = items[i];
if (it && !isVideo(it)) {
const pre = new Image();
pre.src = fileThumbnailUrl(it.id, 'large');
}
}
}
/** The image src to display: large thumbnail first, original on expand/GIF. */
const imgSrc = $derived(
item ? (showingOriginal ? fileInlineUrl(item.id) : fileThumbnailUrl(item.id, 'large')) : ''
);
function onImgError() {
if (!item) return;
// Thumbnail missing → fall back to the original; original failing is terminal.
if (!showingOriginal) showingOriginal = true;
}
function onImgLoad() {
fullResBusy = false;
}
function expandFullRes() {
if (!item || showingOriginal) return;
showingOriginal = true;
fullResBusy = true;
}
function download() {
if (!item) return;
const a = document.createElement('a');
a.href = fileDownloadUrl(item.id);
a.download = item.name;
document.body.appendChild(a);
a.click();
a.remove();
}
async function toggleFavorite() {
if (!item) return;
try {
await addFavorite('file', item.id);
favorited = !favorited;
} catch (e) {
errorToast(e);
}
}
async function remove() {
if (!item) return;
const target = item;
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);
onDelete?.(target.id);
} catch (e) {
errorToast(e);
}
}
function prev() {
if (index > 0) index -= 1;
}
function next() {
if (index >= 0 && index < items.length - 1) index += 1;
}
function close() {
index = -1;
}
function onKeydown(e: KeyboardEvent) {
if (index < 0) return;
if (e.key === 'Escape') close();
else if (e.key === 'ArrowLeft') prev();
else if (e.key === 'ArrowRight') next();
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if item}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="lb"
role="dialog"
aria-modal="true"
aria-label={item.name}
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && close()}
>
<div class="lb__info">
<div class="lb__filename">{item.name}</div>
<div class="lb__meta">{meta}</div>
</div>
<button class="lb__close" aria-label={t('common.close', 'Close')} onclick={close}>×</button>
<button
class="lb__nav lb__nav--prev"
aria-label={t('common.previous', 'Previous')}
disabled={index === 0}
onclick={(e) => {
e.stopPropagation();
prev();
}}><Icon name="chevron-left" /></button
>
<div class="lb__content">
{#if isVideo(item)}
{#key item.id}
<video class="lb__media" controls autoplay poster={fileThumbnailUrl(item.id, 'large')}>
<source src={fileInlineUrl(item.id)} type={item.mime_type} />
</video>
{/key}
{:else}
<img
class="lb__media"
src={imgSrc}
alt={item.name}
onload={onImgLoad}
onerror={onImgError}
/>
{/if}
</div>
<button
class="lb__nav lb__nav--next"
aria-label={t('common.next', 'Next')}
disabled={index === items.length - 1}
onclick={(e) => {
e.stopPropagation();
next();
}}><Icon name="chevron-right" /></button
>
<div class="lb__toolbar">
{#if !isVideo(item) && item.mime_type !== 'image/gif' && !showingOriginal}
<button
class="lb__tool"
title={t('photos.full_resolution', 'Full resolution')}
disabled={fullResBusy}
onclick={expandFullRes}><Icon name={fullResBusy ? 'spinner' : 'expand'} /></button
>
{/if}
<button class="lb__tool" title={t('common.download', 'Download')} onclick={download}>
<Icon name="download" />
</button>
<button
class="lb__tool"
class:active={favorited}
title={t('common.favorite', 'Favorite')}
onclick={toggleFavorite}><Icon name={favorited ? 'star' : 'star-outline'} /></button
>
<button class="lb__tool" title={t('common.delete', 'Delete')} onclick={remove}>
<Icon name="trash" />
</button>
</div>
<div class="lb__counter">{index + 1} / {items.length}</div>
</div>
{/if}
<style>
.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>
@@ -0,0 +1,342 @@
<script lang="ts">
/**
* Places: geotagged photos on a self-hosted MapLibre GL map. Clusters are
* computed server-side (`GET /api/photos/geo`), so we draw one lightweight HTML
* marker per cluster — no glyphs/sprites, no client-side clustering. The vector
* basemap is optional: if `/basemaps/basemap.pmtiles` is present it is read over
* HTTP Range (pmtiles.js); otherwise the map falls back to a themed background
* and still shows the clusters.
*/
import PhotoLightbox from '$lib/components/PhotoLightbox.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { fetchPhotosGeo, type GeoCluster } from '$lib/api/endpoints/photos';
import { fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { minimalPhotoItem } from '$lib/utils/media';
import {
loadMapLibs,
type LngLatBounds,
type MapLibreMap,
type MapLibs,
type MapMarker
} from '$lib/vendor/maplibre';
import { onDestroy, onMount } from 'svelte';
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
let mapEl = $state<HTMLDivElement | null>(null);
let loading = $state(true);
let error = $state(false);
let libs: MapLibs | null = null;
let map: MapLibreMap | null = null;
let markers: MapMarker[] = [];
let moveTimer = 0;
let hasBasemap: boolean | null = null;
// Lightbox drill-in (single representative photo).
let lbItems = $state<FileItem[]>([]);
let lbIndex = $state(-1);
function isDark(): boolean {
const attr = document.documentElement.getAttribute('data-color-scheme');
if (attr === 'dark') return true;
if (attr === 'light') return false;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
}
/** Whether a basemap .pmtiles is available (cached after first probe). */
async function checkBasemap(): Promise<boolean> {
if (hasBasemap !== null) return hasBasemap;
try {
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
hasBasemap = res.ok; // 200/206 = present, 404 = absent
} catch {
hasBasemap = false;
}
return hasBasemap;
}
/** Minimal MapLibre style: themed background only (no basemap). */
function blankStyle(): Record<string, unknown> {
return {
version: 8,
sources: {},
layers: [
{
id: 'bg',
type: 'background',
paint: { 'background-color': isDark() ? '#0f172a' : '#e8eef3' }
}
]
};
}
/** Label-light Protomaps vector style (no glyphs/sprites required). */
function basemapStyle(): Record<string, unknown> {
const dark = isDark();
const c = dark
? {
earth: '#1b2433',
land: '#222d3d',
water: '#0d1b2a',
roads: '#3a4860',
buildings: '#2a3547',
boundary: '#475569'
}
: {
earth: '#f3efe9',
land: '#e9e4da',
water: '#a8c8e8',
roads: '#ffffff',
buildings: '#e0dccf',
boundary: '#c9c2b6'
};
return {
version: 8,
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${BASEMAP_URL}`,
attribution: 'Protomaps © OpenStreetMap'
}
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
{
id: 'earth',
type: 'fill',
source: 'protomaps',
'source-layer': 'earth',
paint: { 'fill-color': c.earth }
},
{
id: 'landuse',
type: 'fill',
source: 'protomaps',
'source-layer': 'landuse',
paint: { 'fill-color': c.land, 'fill-opacity': 0.6 }
},
{
id: 'water',
type: 'fill',
source: 'protomaps',
'source-layer': 'water',
paint: { 'fill-color': c.water }
},
{
id: 'roads',
type: 'line',
source: 'protomaps',
'source-layer': 'roads',
minzoom: 7,
paint: { 'line-color': c.roads, 'line-width': 0.8 }
},
{
id: 'buildings',
type: 'fill',
source: 'protomaps',
'source-layer': 'buildings',
minzoom: 13,
paint: { 'fill-color': c.buildings }
},
{
id: 'boundaries',
type: 'line',
source: 'protomaps',
'source-layer': 'boundaries',
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
}
]
};
}
async function initMap() {
if (!mapEl) return;
try {
libs = await loadMapLibs();
} catch {
error = true;
loading = false;
return;
}
const { maplibregl, pmtiles } = libs;
const basemap = await checkBasemap();
if (basemap) {
try {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
} catch {
/* fall through to a basemap-less map */
}
}
map = new maplibregl.Map({
container: mapEl,
style: basemap ? basemapStyle() : blankStyle(),
center: [0, 25],
zoom: 1.3,
attributionControl: false
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
if (basemap) {
map.addControl(
new maplibregl.AttributionControl({
customAttribution:
'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
})
);
}
map.on('load', () => {
loading = false;
void refreshClusters(true);
});
map.on('moveend', () => {
clearTimeout(moveTimer);
moveTimer = window.setTimeout(() => void refreshClusters(false), 250);
});
}
/** Fetch clusters for the current viewport and render them.
* @param fit Fit the map to the returned clusters (first load only). */
async function refreshClusters(fit: boolean) {
if (!map) return;
const b = map.getBounds();
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
const zoom = Math.round(map.getZoom());
try {
const clusters = await fetchPhotosGeo(bbox, zoom);
renderMarkers(clusters);
if (fit && clusters.length) fitTo(clusters);
} catch {
/* transient geo fetch failure — leave the current markers in place */
}
}
function renderMarkers(clusters: GeoCluster[]) {
for (const m of markers) m.remove();
markers = [];
if (!libs || !map) return;
const { maplibregl } = libs;
for (const c of clusters) {
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
const el = document.createElement('div');
el.className = 'places-cluster';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.backgroundImage = `url(${fileThumbnailUrl(c.sample_file_id, 'icon')})`;
if (c.count > 1) {
const count = document.createElement('span');
count.className = 'places-cluster__count';
count.textContent = String(c.count);
el.appendChild(count);
}
el.addEventListener('click', () => onClusterClick(c));
markers.push(new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(map));
}
}
function onClusterClick(c: GeoCluster) {
if (!map) return;
const zoom = map.getZoom();
if (c.count === 1 || zoom >= 16) {
lbItems = [minimalPhotoItem(c.sample_file_id)];
lbIndex = 0;
} else {
map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
}
}
function fitTo(clusters: GeoCluster[]) {
if (!libs || !map) return;
const bounds: LngLatBounds = new libs.maplibregl.LngLatBounds();
for (const c of clusters) bounds.extend([c.lng, c.lat]);
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
}
onMount(initMap);
onDestroy(() => {
clearTimeout(moveTimer);
for (const m of markers) m.remove();
markers = [];
map?.remove();
map = null;
});
</script>
<div class="places">
<div class="places__map" bind:this={mapEl}></div>
{#if loading && !error}
<div class="places__loading"><Icon name="spinner" /></div>
{/if}
{#if error}
<div class="places__error">{t('photos.map_error', 'Could not load the map')}</div>
{/if}
</div>
<PhotoLightbox items={lbItems} bind:index={lbIndex} />
<style>
.places {
position: relative;
height: calc(100vh - 8rem);
min-height: 24rem;
}
.places__map {
position: absolute;
inset: 0;
}
.places__loading,
.places__error {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--color-text-muted);
pointer-events: none;
}
.places__loading :global(svg) {
animation: places-spin 1s linear infinite;
font-size: 1.5rem;
}
@keyframes places-spin {
to {
transform: rotate(360deg);
}
}
/* Cluster markers are created imperatively by MapLibre, outside Svelte's
scoped styles — hence :global. */
:global(.places-cluster) {
position: relative;
border-radius: 50%;
background-size: cover;
background-position: center;
border: 2px solid var(--color-on-accent);
box-shadow: 0 1px 4px var(--color-overlay-shadow);
cursor: pointer;
}
:global(.places-cluster__count) {
position: absolute;
top: -6px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 9px;
background: var(--color-accent);
color: var(--color-on-accent);
font-size: 11px;
font-weight: var(--weight-bold);
display: grid;
place-items: center;
}
</style>
@@ -0,0 +1,673 @@
<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 EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonList from '$lib/components/SkeletonList.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}
<EmptyState icon="exclamation-circle" title={error} error />
{:else if loading && isEmpty}
<SkeletonList count={SKELETON.length} />
{:else if isEmpty}
<EmptyState
icon={emptyIcon}
title={emptyText ?? t('common.empty', 'Nothing here yet.')}
hint={emptyHint}
/>
{: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);
}
</style>
@@ -0,0 +1,854 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
import {
copyShareLink,
createShare,
deleteShare,
listSharesForItem,
updateShare
} from '$lib/api/endpoints/shares';
import {
createGrant,
expiryToIso,
displayRole,
fetchGrantsForResource,
notifyGrantRecipient,
revokeGrant,
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: 'owner', 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[] = ['owner', '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; role: ShareRole; 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,
role: 'viewer' as ShareRole,
ids: [],
expiry: null
};
// Role-grants emit one row per (subject, resource), so the row's role
// is the subject's role directly.
entry.role = displayRole(g.role);
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: e.role,
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) {
errorToast(e);
} 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) {
errorToast(e);
}
}
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) {
errorToast(e);
}
}
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) {
errorToast(e);
}
}
async function removeMember(m: Member) {
try {
for (const id of m.grantIds) await revokeGrant(id);
await loadGrants();
} catch (e) {
errorToast(e);
}
}
async function notifyMember(m: Member) {
if (!m.notifyGrantId) return;
try {
const set = await notifyGrantRecipient(m.notifyGrantId);
summarizeNotifications(set.outcomes);
} catch (e) {
errorToast(e);
}
}
/** 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) {
errorToast(e);
} 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) {
errorToast(e);
} finally {
creating = false;
}
}
async function editLinkExpiry(share: ShareItem, expiry: string | null) {
try {
await updateShare(share.id, { expiresAt: expiry });
await loadShares();
} catch (e) {
errorToast(e);
}
}
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) {
errorToast(e);
}
}
async function removeLink(share: ShareItem) {
try {
await deleteShare(share.id);
shares = shares.filter((s) => s.id !== share.id);
} catch (e) {
errorToast(e);
}
}
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_label', '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>
@@ -0,0 +1,32 @@
<script lang="ts">
import { files as filesStore } from '$lib/stores/files.svelte';
interface Props {
/** Number of placeholder cards/rows to render (default 6). */
count?: number;
}
let { count = 6 }: Props = $props();
const placeholders = $derived(Array.from({ length: count }, (_, i) => i));
</script>
<div class="files-container">
<div class={filesStore.viewMode === 'grid' ? 'files-grid-view files-skeleton' : 'files-skeleton'}>
{#each placeholders 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 class="skeleton skeleton-line skeleton-line--short"></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>
@@ -0,0 +1,88 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
import { t } from '$lib/i18n/index.svelte';
</script>
<div
class="toaster"
role="region"
aria-live="polite"
aria-label={t('notifications.title', 'Notifications')}
>
{#each ui.toasts as toast (toast.id)}
<div class="toast toast--{toast.kind}" role="status">
<span class="toast__msg">{toast.message}</span>
<button
class="toast__close"
aria-label={t('common.dismiss', 'Dismiss')}
onclick={() => ui.dismiss(toast.id)}
>
×
</button>
</div>
{/each}
</div>
<style>
.toaster {
position: fixed;
/* 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: 1200;
max-width: min(92vw, 24rem);
/* Let clicks pass through the gaps; individual toasts re-enable below. */
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
box-shadow: var(--shadow-md);
border-left: 4px solid var(--color-border);
pointer-events: auto;
}
.toast--success {
border-left-color: var(--color-success-text);
}
.toast--error {
border-left-color: var(--color-danger-text);
}
.toast--warning {
border-left-color: var(--color-warning-text);
}
.toast--info {
border-left-color: var(--color-primary);
}
.toast__msg {
flex: 1;
}
.toast__close {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
color: inherit;
opacity: 0.7;
}
.toast__close:hover {
opacity: 1;
}
</style>
@@ -0,0 +1,173 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
import { getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.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) => {
errorToast(e);
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>
@@ -0,0 +1,48 @@
/**
* Reactive cache of owner-id → display-name, with memoised parallel resolution.
*
* Replaces the identical `ownerNames` record + `resolveOwners()` block in the
* favorites and recent views. The id→name resolver is injected so the cache
* stays decoupled from any specific API endpoint.
*/
export class OwnerCache {
#names = $state<Record<string, string>>({});
#resolver: (id: string) => Promise<string>;
constructor(resolver: (id: string) => Promise<string>) {
this.#resolver = resolver;
}
/** Resolved names so far (id → display name). */
get names(): Record<string, string> {
return this.#names;
}
/** Display name for an id, or `null` when unknown/empty (for cell rendering). */
name(id: string | null | undefined): string | null {
if (!id) return null;
return this.#names[id] ?? null;
}
/** Display name for an id, falling back to the id itself (for group labels). */
label(id: string): string {
return this.#names[id] ?? id;
}
/** Resolve every not-yet-cached id in parallel; nullish ids are skipped. */
async resolve(ids: Iterable<string | null | undefined>): Promise<void> {
const unique = [...new Set([...ids].filter((id): id is string => !!id))];
await Promise.all(
unique.map(async (id) => {
if (this.#names[id]) return;
const name = await this.#resolver(id);
this.#names = { ...this.#names, [id]: name };
})
);
}
}
/** Create a reactive {@link OwnerCache} backed by `resolver`. */
export function useOwnerCache(resolver: (id: string) => Promise<string>): OwnerCache {
return new OwnerCache(resolver);
}
@@ -0,0 +1,65 @@
/**
* Reactive multi-select over string ids. Backs the repeated
* `let selected = $state(new Set()); function toggle(id) { … }` pattern used by
* the photos grid, music picker and other list views with one source of truth.
*
* Mutations swap in a fresh Set so `$derived`/template reads re-run.
*/
export class Selection {
#ids = $state<Set<string>>(new Set());
/** The live selection set (read-only intent — mutate via the methods). */
get ids(): Set<string> {
return this.#ids;
}
get size(): number {
return this.#ids.size;
}
get isEmpty(): boolean {
return this.#ids.size === 0;
}
has(id: string): boolean {
return this.#ids.has(id);
}
/** Selected ids as an array (e.g. for batch API calls). */
values(): string[] {
return [...this.#ids];
}
toggle(id: string): void {
const next = new Set(this.#ids);
if (next.has(id)) next.delete(id);
else next.add(id);
this.#ids = next;
}
add(id: string): void {
if (this.#ids.has(id)) return;
this.#ids = new Set(this.#ids).add(id);
}
delete(id: string): void {
if (!this.#ids.has(id)) return;
const next = new Set(this.#ids);
next.delete(id);
this.#ids = next;
}
/** Replace the whole selection. */
set(ids: Iterable<string>): void {
this.#ids = new Set(ids);
}
clear(): void {
if (this.#ids.size) this.#ids = new Set();
}
}
/** Create a reactive {@link Selection}. */
export function useSelection(): Selection {
return new Selection();
}
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { getNestedValue, interpolate, resolveBrowserLocale } from './index.svelte';
describe('resolveBrowserLocale', () => {
it('matches an exact full tag', () => {
expect(resolveBrowserLocale(['zh-TW'])).toBe('zh-TW');
expect(resolveBrowserLocale(['fr-FR', 'fr'])).toBe('fr');
});
it('maps Traditional Chinese variants to zh-TW', () => {
expect(resolveBrowserLocale(['zh-Hant'])).toBe('zh-TW');
expect(resolveBrowserLocale(['zh-HK'])).toBe('zh-TW');
expect(resolveBrowserLocale(['zh-MO'])).toBe('zh-TW');
});
it('maps Simplified/other Chinese to zh', () => {
expect(resolveBrowserLocale(['zh-CN'])).toBe('zh');
expect(resolveBrowserLocale(['zh'])).toBe('zh');
});
it('falls back to the primary subtag', () => {
expect(resolveBrowserLocale(['de-AT'])).toBe('de');
});
it('defaults to en when nothing matches', () => {
expect(resolveBrowserLocale(['xx-YY'])).toBe('en');
});
});
describe('getNestedValue', () => {
const dict = {
'flat.key': 'flat value',
nav: { files: 'Files', shared: 'Shared' },
button: { save_changes: 'Save changes' }
};
it('resolves a direct key that contains dots', () => {
expect(getNestedValue(dict, 'flat.key')).toBe('flat value');
});
it('resolves dotted nested paths', () => {
expect(getNestedValue(dict, 'nav.files')).toBe('Files');
});
it('returns null for missing keys', () => {
expect(getNestedValue(dict, 'nav.missing')).toBeNull();
expect(getNestedValue(undefined, 'nav.files')).toBeNull();
});
it('applies the prefix_suffix underscore fallback', () => {
expect(getNestedValue(dict, 'button_save_changes')).toBe('Save changes');
});
});
describe('interpolate', () => {
it('replaces {{param}} placeholders', () => {
expect(interpolate('Hello {{name}}', { name: 'Ada' })).toBe('Hello Ada');
});
it('trims whitespace inside placeholders', () => {
expect(interpolate('Send to {{ email }}', { email: 'a@b.c' })).toBe('Send to a@b.c');
});
it('leaves unknown placeholders intact', () => {
expect(interpolate('Hi {{name}}', {})).toBe('Hi {{name}}');
});
it('coerces non-string params', () => {
expect(interpolate('{{count}} items', { count: 5 })).toBe('5 items');
});
});
+250
View File
@@ -0,0 +1,250 @@
/**
* Reactive i18n — ported from static/js/core/i18n.js.
*
* Kept as a bespoke module (rather than svelte-i18n) so the 16 existing locale
* JSON files work byte-for-byte: they use `{{param}}` interpolation, dot-notation
* nested keys, and a prefix_suffix underscore-fallback heuristic that ICU-based
* libraries don't model. `t()` reads module-level runes, so any component that
* calls it re-renders when the locale changes.
*
* Storage key `oxicloud-locale` and the server round-trip via
* PATCH /api/auth/me/profile are preserved for cross-device/email parity.
*/
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 language selector.
export const SUPPORTED_LOCALES = [
'en',
'es',
'zh',
'zh-TW',
'fa',
'fr',
'de',
'pt',
'nl',
'it',
'hi',
'ar',
'ru',
'ja',
'ko',
'pl'
] as const;
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>;
/**
* Resolve the best supported locale from a browser language list.
* Priority: exact full-tag > Chinese script/region heuristics > primary subtag.
*/
export function resolveBrowserLocale(
langs: readonly string[] = typeof navigator !== 'undefined'
? (navigator.languages ?? [navigator.language || 'en'])
: ['en']
): Locale {
const lowerSupported = SUPPORTED_LOCALES.map((l) => l.toLowerCase());
for (const bl of langs) {
const idx = lowerSupported.indexOf(bl.toLowerCase());
if (idx !== -1) return SUPPORTED_LOCALES[idx];
}
for (const bl of langs) {
const tag = bl.toLowerCase();
if (!tag.startsWith('zh')) continue;
const isTraditional = tag.includes('hant') || /\b(tw|hk|mo)\b/.test(tag);
const target: Locale = isTraditional ? 'zh-TW' : 'zh';
if (SUPPORTED_LOCALES.includes(target)) return target;
}
for (const bl of langs) {
const primary = bl.substring(0, 2).toLowerCase();
const match = SUPPORTED_LOCALES.find((l) => l === primary);
if (match) return match;
}
return 'en';
}
/** Resolve a dot-notation key with a prefix_suffix underscore fallback. */
export function getNestedValue(obj: Dict | undefined, path: string): string | null {
if (obj && typeof obj === 'object' && path in obj) {
const value = obj[path];
return typeof value === 'string' ? value : null;
}
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in (current as Dict)) {
current = (current as Dict)[key];
} else {
if (path.includes('_') && !path.includes('.')) {
const [prefix, ...parts] = path.split('_');
const suffix = parts.join('_');
const branch = obj?.[prefix];
if (branch && typeof branch === 'object' && suffix in (branch as Dict)) {
const v = (branch as Dict)[suffix];
return typeof v === 'string' ? v : null;
}
}
return null;
}
}
return typeof current === 'string' ? current : null;
}
/** Replace `{{param}}` placeholders; leaves unknown placeholders intact. */
export function interpolate(text: string, params: Record<string, unknown>): string {
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key: string) => {
const k = key.trim();
return params[k] !== undefined ? String(params[k]) : `{{${key}}}`;
});
}
// ── Reactive state ─────────────────────────────────────────────────────────
const dicts = $state<Record<string, Dict>>({});
const store = $state<{ locale: string; loaded: boolean }>({
locale: resolveBrowserLocale(),
loaded: false
});
async function loadDict(locale: string): Promise<Dict> {
if (dicts[locale]) return dicts[locale];
try {
const res = await fetch(`/locales/${locale}.json`);
if (!res.ok) throw new Error(`locale ${locale} ${res.status}`);
dicts[locale] = (await res.json()) as Dict;
} catch (err) {
console.error('i18n: failed to load locale', locale, err);
dicts[locale] = {};
}
return dicts[locale];
}
/**
* Translate a key.
* - `t(key)` / `t(key, params)` — interpolation params object.
* - `t(key, fallback)` — string fallback used when the key is missing.
* - `t(key, params, fallback)` — both; the fallback is also interpolated.
*/
export function t(
key: string,
paramsOrFallback: string | Record<string, unknown> = {},
fallbackArg?: string
): string {
const isStringForm = typeof paramsOrFallback === 'string';
const params = isStringForm ? {} : paramsOrFallback;
const fallback = isStringForm ? paramsOrFallback : (fallbackArg ?? null);
const localeData = dicts[store.locale];
if (!localeData) {
return fallback ? interpolate(fallback, params) : (key.split('.').pop() ?? key);
}
let value = getNestedValue(localeData, key);
if (!value && store.locale !== 'en' && dicts.en) {
value = getNestedValue(dicts.en, key);
}
if (!value) return fallback ? interpolate(fallback, params) : key;
return interpolate(value, params);
}
export async function initI18n(): Promise<void> {
const saved = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null;
if (saved && (SUPPORTED_LOCALES as readonly string[]).includes(saved)) {
store.locale = saved;
}
await loadDict(store.locale);
if (store.locale !== 'en') await loadDict('en');
applyHtmlLang(store.locale);
store.loaded = true;
}
export async function setLocale(locale: Locale): Promise<boolean> {
if (!(SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
console.error(`Locale not supported: ${locale}`);
return false;
}
await loadDict(locale);
store.locale = locale;
applyHtmlLang(locale);
if (typeof localStorage !== 'undefined') localStorage.setItem(STORAGE_KEY, locale);
persistLocaleToServer(locale);
return true;
}
/** Fire-and-forget server persistence; anonymous callers 401 and that's fine. */
function persistLocaleToServer(locale: string): void {
apiFetch('/api/auth/me/profile', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
credentials: 'same-origin',
body: JSON.stringify({ preferred_locale: locale })
}).catch((err: unknown) => {
console.debug('locale: server persistence skipped', err);
});
}
export const i18n = {
t,
setLocale,
get locale() {
return store.locale;
},
get loaded() {
return store.loaded;
},
supported: SUPPORTED_LOCALES
};
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts">
import { OxiIcons, type IconName } from './registry';
interface Props {
/** FA5-style icon name (without the `fa-` prefix), e.g. "folder". */
name: IconName | string;
/** Accessible label; when omitted the icon is decorative (aria-hidden). */
title?: string;
/** Extra classes forwarded to the <svg>. */
class?: string;
}
let { name, title, class: className = '' }: Props = $props();
const entry = $derived(OxiIcons[name as IconName]);
const width = $derived(entry?.[0] ?? 512);
const path = $derived(entry?.[1] ?? '');
</script>
{#if entry}
<svg
class={`oxi-icon ${className}`}
viewBox={`0 0 ${width} 512`}
fill="currentColor"
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : 'true'}
aria-label={title}
>
{#if title}<title>{title}</title>{/if}
<path d={path} />
</svg>
{/if}
<style>
.oxi-icon {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
</style>
+523
View File
@@ -0,0 +1,523 @@
// AUTO-PORTED from static/js/core/icons.js (Font Awesome Free 6.7.2, CC BY 4.0).
// Each entry: [viewBox-width, path-d]. All icons use viewBox "0 0 {width} 512"
// and fill="currentColor". Keys use FA5 class names (without the "fa-" prefix).
// Do not edit by hand; regenerate from the source registry if icons change.
export type IconEntry = readonly [number, string];
export const OxiIcons: Record<string, IconEntry> = {
"arrow-down": [
512,
"M169.4 502.6c12.5 12.5 32.8 12.5 45.3 0l160-160c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L224 402.7 224 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 370.7-105.4-105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l160 160z"
],
"arrow-down-short-wide": [
576,
"M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0c-17.7 0-32-14.3-32-32s14.3-32 32-32zm0 128l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-224 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"
],
"arrow-down-wide-short": [
576,
"M246.6 374.6l-96 96c-12.5 12.5-32.8 12.5-45.3 0l-96-96c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L96 370.7 96 64c0-17.7 14.3-32 32-32s32 14.3 32 32l0 306.7 41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3zM320 480c-17.7 0-32-14.3-32-32s14.3-32 32-32l32 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-32 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-96 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l160 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-160 0zm0-128c-17.7 0-32-14.3-32-32s14.3-32 32-32l224 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L320 96z"
],
"arrow-left": [
448,
"M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.2 288 416 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0L214.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"
],
"arrow-up": [
512,
"M214.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 109.3 160 480c0 17.7 14.3 32 32 32s32-14.3 32-32l0-370.7 105.4 105.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
],
"arrow-up-a-z": [
576,
"M183.6 42.4C177.5 35.8 169 32 160 32s-17.5 3.8-23.6 10.4l-88 96c-11.9 13-11.1 33.3 2 45.2s33.3 11.1 45.2-2L128 146.3 128 448c0 17.7 14.3 32 32 32s32-14.3 32-32l0-301.7 32.4 35.4c11.9 13 32.2 13.9 45.2 2s13.9-32.2 2-45.2l-88-96zM320 320c0 17.7 14.3 32 32 32l50.7 0-73.4 73.4c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l128 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-50.7 0 73.4-73.4c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-128 0c-17.7 0-32 14.3-32 32zM416 32c-12.1 0-23.2 6.8-28.6 17.7l-64 128-16 32c-7.9 15.8-1.5 35 14.3 42.9s35 1.5 42.9-14.3l7.2-14.3 88.4 0 7.2 14.3c7.9 15.8 27.1 22.2 42.9 14.3s22.2-27.1 14.3-42.9l-16-32-64-128C439.2 38.8 428.1 32 416 32zM395.8 176L416 135.6 436.2 176l-40.4 0z"
],
"arrows-alt": [
512,
"M278.6 9.4c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-9.2 9.2-11.9 22.9-6.9 34.9s16.6 19.8 29.6 19.8l32 0 0 96-96 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9s19.8-16.6 19.8-29.6l0-32 96 0 0 96-32 0c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l64 64c12.5 12.5 32.8 12.5 45.3 0l64-64c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8l-32 0 0-96 96 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9s-19.8 16.6-19.8 29.6l0 32-96 0 0-96 32 0c12.9 0 24.6-7.8 29.6-19.8s2.2-25.7-6.9-34.9l-64-64z"
],
"backward": [
512,
"M204.3 43.1C215.9 32 233 28.9 247.7 35.2S272 56 272 72l0 136.3 172.3-165.1C455.9 32 473 28.9 487.7 35.2S512 56 512 72l0 368c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9L272 303.7 272 440c0 16-9.6 30.5-24.3 36.8s-31.8 3.2-43.4-7.9l-192-184C4.5 277.3 0 266.9 0 256s4.5-21.3 12.3-28.9l192-184z"
],
"ban": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM159.3 388.7L388.7 159.3c4.6-4.6 11.5-5.9 17.4-3.5c14.5 6 26.4 15.3 35.1 27c3.8 5.2 3.2 12.3-1.2 16.8L210.2 428.4c-4.4 4.4-11.6 5-16.8 1.2c-11.7-8.7-21-20.6-27-35.1c-2.5-5.9-1.1-12.8 3.5-17.4z"
],
"bars": [
512,
"M96 160C96 142.3 110.3 128 128 128L512 128C529.7 128 544 142.3 544 160C544 177.7 529.7 192 512 192L128 192C110.3 192 96 177.7 96 160zM96 320C96 302.3 110.3 288 128 288L512 288C529.7 288 544 302.3 544 320C544 337.7 529.7 352 512 352L128 352C110.3 352 96 337.7 96 320zM544 480C544 497.7 529.7 512 512 512L128 512C110.3 512 96 497.7 96 480C96 462.3 110.3 448 128 448L512 448C529.7 448 544 462.3 544 480z"
],
"bell": [
448,
"M224 0c-17.7 0-32 14.3-32 32l0 19.2C119 66 64 130.6 64 208l0 18.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S19.4 416 32 416l384 0c12.6 0 24-7.4 29.2-18.9s3.1-25-5.3-34.4l-7.4-8.3C401.3 319.2 384 273.9 384 226.8l0-18.8c0-77.4-55-142-128-156.8L256 32c0-17.7-14.3-32-32-32zm45.3 493.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"
],
"bell-slash": [
640,
"M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7l-90.2-70.7c.2-.4 .4-.9 .6-1.3c5.2-11.5 3.1-25-5.3-34.4l-7.4-8.3C497.3 319.2 480 273.9 480 226.8l0-18.8c0-77.4-55-142-128-156.8L352 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 19.2c-42.6 8.6-79 34.2-102 69.3L38.8 5.1zM406.2 416L160 222.1l0 4.8c0 47-17.3 92.4-48.5 127.6l-7.4 8.3c-8.4 9.4-10.4 22.9-5.3 34.4S115.4 416 128 416l278.2 0zm-40.9 77.3c12-12 18.7-28.3 18.7-45.3l-64 0-64 0c0 17 6.7 33.3 18.7 45.3s28.3 18.7 45.3 18.7s33.3-6.7 45.3-18.7z"
],
"box": [
448,
"M50.7 58.5L0 160l208 0 0-128L93.7 32C75.5 32 58.9 42.3 50.7 58.5zM240 160l208 0L397.3 58.5C389.1 42.3 372.5 32 354.3 32L240 32l0 128zm208 32L0 192 0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-224z"
],
"broom": [
576,
"M566.6 54.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192-34.7-34.7c-4.2-4.2-10-6.6-16-6.6c-12.5 0-22.6 10.1-22.6 22.6l0 29.1L364.3 320l29.1 0c12.5 0 22.6-10.1 22.6-22.6c0-6-2.4-11.8-6.6-16l-34.7-34.7 192-192zM341.1 353.4L222.6 234.9c-42.7-3.7-85.2 11.7-115.8 42.3l-8 8C76.5 307.5 64 337.7 64 369.2c0 6.8 7.1 11.2 13.2 8.2l51.1-25.5c5-2.5 9.5 4.1 5.4 7.9L7.3 473.4C2.7 477.6 0 483.6 0 489.9C0 502.1 9.9 512 22.1 512l173.3 0c38.8 0 75.9-15.4 103.4-42.8c30.6-30.6 45.9-73.1 42.3-115.8z"
],
"building-circle-check": [
576,
"M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM576 400a144 144 0 1 0 -288 0 144 144 0 1 0 288 0zm-86.6-60.9c7.1 5.2 8.7 15.2 3.5 22.3l-64 88c-2.8 3.8-7 6.2-11.7 6.5s-9.3-1.3-12.6-4.6l-40-40c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l26.8 26.8 53-72.9c5.2-7.1 15.2-8.7 22.4-3.5z"
],
"building-circle-xmark": [
576,
"M96 0C60.7 0 32 28.7 32 64l0 384c0 35.3 28.7 64 64 64l180 0c-10.5-14.6-19-30.7-25.1-48l-74.9 0 0-80c0-17.7 14.3-32 32-32l32 0c2 0 4 .2 5.9 .5 6-23.6 16.3-45.4 30.1-64.5l-4 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 4c27.5-19.8 60.3-32.4 96-35.4L416 64c0-35.3-28.7-64-64-64L96 0zm32 112c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM272 96l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM128 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM432 544a144 144 0 1 0 0-288 144 144 0 1 0 0 288zm22.6-144l36.7 36.7c6.2 6.2 6.2 16.4 0 22.6s-16.4 6.2-22.6 0l-36.7-36.7-36.7 36.7c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l36.7-36.7-36.7-36.7c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0l36.7 36.7 36.7-36.7c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6L454.6 400z"
],
"calendar": [
512,
"M120 0c13.3 0 24 10.7 24 24l0 40 160 0 0-40c0-13.3 10.7-24 24-24s24 10.7 24 24l0 40 32 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 128C0 92.7 28.7 64 64 64l32 0 0-40c0-13.3 10.7-24 24-24zm0 112l-56 0c-8.8 0-16 7.2-16 16l0 48 352 0 0-48c0-8.8-7.2-16-16-16l-264 0zM48 224l0 192c0 8.8 7.2 16 16 16l320 0c8.8 0 16-7.2 16-16l0-192-352 0z"
],
"camera": [
512,
"M149.1 64.8L138.7 96 64 96C28.7 96 0 124.7 0 160L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64l-74.7 0-10.4-31.2C356.4 45.2 338.1 32 317.4 32L194.6 32c-20.7 0-39 13.2-45.5 32.8zM256 192a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"
],
"caret-down": [
320,
"M137.4 374.6c12.5 12.5 32.8 12.5 45.3 0l128-128c9.2-9.2 11.9-22.9 6.9-34.9s-16.6-19.8-29.6-19.8L32 192c-12.9 0-24.6 7.8-29.6 19.8s-2.2 25.7 6.9 34.9l128 128z"
],
"chart-pie": [
576,
"M304 240l0-223.4c0-9 7-16.6 16-16.6C443.7 0 544 100.3 544 224c0 9-7.6 16-16.6 16L304 240zM32 272C32 150.7 122.1 50.3 239 34.3c9.2-1.3 17 6.1 17 15.4L256 288 412.5 444.5c6.7 6.7 6.2 17.7-1.5 23.1C371.8 495.6 323.8 512 272 512C139.5 512 32 404.6 32 272zm526.4 16c9.3 0 16.6 7.8 15.4 17c-7.7 55.9-34.6 105.6-73.9 142.3c-6 5.6-15.4 5.2-21.2-.7L320 288l238.4 0z"
],
"check": [
448,
"M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"
],
"check-circle": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
],
"check-double": [
448,
"M342.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 178.7l-57.4-57.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l80 80c12.5 12.5 32.8 12.5 45.3 0l160-160zm96 128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 402.7 54.6 297.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l256-256z"
],
"chevron-down": [
512,
"M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"
],
"chevron-left": [
320,
"M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"
],
"chevron-right": [
320,
"M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z"
],
"circle-notch": [
512,
"M222.7 32.1c5 16.9-4.6 34.8-21.5 39.8C121.8 95.6 64 169.1 64 256c0 106 86 192 192 192s192-86 192-192c0-86.9-57.8-160.4-137.1-184.1c-16.9-5-26.6-22.9-21.5-39.8s22.9-26.6 39.8-21.5C434.9 42.1 512 140 512 256c0 141.4-114.6 256-256 256S0 397.4 0 256C0 140 77.1 42.1 182.9 10.6c16.9-5 34.8 4.6 39.8 21.5z"
],
"clock": [
512,
"M256 0a256 256 0 1 1 0 512A256 256 0 1 1 256 0zM232 120l0 136c0 8 4 15.5 10.7 20l96 64c11 7.4 25.9 4.4 33.3-6.7s4.4-25.9-6.7-33.3L280 243.2 280 120c0-13.3-10.7-24-24-24s-24 10.7-24 24z"
],
"cloud": [
640,
"M0 336c0 79.5 64.5 144 144 144l368 0c70.7 0 128-57.3 128-128c0-61.9-44-113.6-102.4-125.4c4.1-10.7 6.4-22.4 6.4-34.6c0-53-43-96-96-96c-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32C167.6 32 96 103.6 96 192c0 2.7 .1 5.4 .2 8.1C40.2 219.8 0 273.2 0 336z"
],
"cloud-upload-alt": [
640,
"M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128l-368 0zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39L296 392c0 13.3 10.7 24 24 24s24-10.7 24-24l0-134.1 39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z"
],
"cog": [
512,
"M495.9 166.6c3.2 8.7 .5 18.4-6.4 24.6l-43.3 39.4c1.1 8.3 1.7 16.8 1.7 25.4s-.6 17.1-1.7 25.4l43.3 39.4c6.9 6.2 9.6 15.9 6.4 24.6c-4.4 11.9-9.7 23.3-15.8 34.3l-4.7 8.1c-6.6 11-14 21.4-22.1 31.2c-5.9 7.2-15.7 9.6-24.5 6.8l-55.7-17.7c-13.4 10.3-28.2 18.9-44 25.4l-12.5 57.1c-2 9.1-9 16.3-18.2 17.8c-13.8 2.3-28 3.5-42.5 3.5s-28.7-1.2-42.5-3.5c-9.2-1.5-16.2-8.7-18.2-17.8l-12.5-57.1c-15.8-6.5-30.6-15.1-44-25.4L83.1 425.9c-8.8 2.8-18.6 .3-24.5-6.8c-8.1-9.8-15.5-20.2-22.1-31.2l-4.7-8.1c-6.1-11-11.4-22.4-15.8-34.3c-3.2-8.7-.5-18.4 6.4-24.6l43.3-39.4C64.6 273.1 64 264.6 64 256s.6-17.1 1.7-25.4L22.4 191.2c-6.9-6.2-9.6-15.9-6.4-24.6c4.4-11.9 9.7-23.3 15.8-34.3l4.7-8.1c6.6-11 14-21.4 22.1-31.2c5.9-7.2 15.7-9.6 24.5-6.8l55.7 17.7c13.4-10.3 28.2-18.9 44-25.4l12.5-57.1c2-9.1 9-16.3 18.2-17.8C227.3 1.2 241.5 0 256 0s28.7 1.2 42.5 3.5c9.2 1.5 16.2 8.7 18.2 17.8l12.5 57.1c15.8 6.5 30.6 15.1 44 25.4l55.7-17.7c8.8-2.8 18.6-.3 24.5 6.8c8.1 9.8 15.5 20.2 22.1 31.2l4.7 8.1c6.1 11 11.4 22.4 15.8 34.3zM256 336a80 80 0 1 0 0-160 80 80 0 1 0 0 160z"
],
"cogs": [
640,
"M308.5 135.3c7.1-6.3 9.9-16.2 6.2-25c-2.3-5.3-4.8-10.5-7.6-15.5L304 89.4c-3-5-6.3-9.9-9.8-14.6c-5.7-7.6-15.7-10.1-24.7-7.1l-28.2 9.3c-10.7-8.8-23-16-36.2-20.9L199 27.1c-1.9-9.3-9.1-16.7-18.5-17.8C173.9 8.4 167.2 8 160.4 8l-.7 0c-6.8 0-13.5 .4-20.1 1.2c-9.4 1.1-16.6 8.6-18.5 17.8L115 56.1c-13.3 5-25.5 12.1-36.2 20.9L50.5 67.8c-9-3-19-.5-24.7 7.1c-3.5 4.7-6.8 9.6-9.9 14.6l-3 5.3c-2.8 5-5.3 10.2-7.6 15.6c-3.7 8.7-.9 18.6 6.2 25l22.2 19.8C32.6 161.9 32 168.9 32 176s.6 14.1 1.7 20.9L11.5 216.7c-7.1 6.3-9.9 16.2-6.2 25c2.3 5.3 4.8 10.5 7.6 15.6l3 5.2c3 5.1 6.3 9.9 9.9 14.6c5.7 7.6 15.7 10.1 24.7 7.1l28.2-9.3c10.7 8.8 23 16 36.2 20.9l6.1 29.1c1.9 9.3 9.1 16.7 18.5 17.8c6.7 .8 13.5 1.2 20.4 1.2s13.7-.4 20.4-1.2c9.4-1.1 16.6-8.6 18.5-17.8l6.1-29.1c13.3-5 25.5-12.1 36.2-20.9l28.2 9.3c9 3 19 .5 24.7-7.1c3.5-4.7 6.8-9.5 9.8-14.6l3.1-5.4c2.8-5 5.3-10.2 7.6-15.5c3.7-8.7 .9-18.6-6.2-25l-22.2-19.8c1.1-6.8 1.7-13.8 1.7-20.9s-.6-14.1-1.7-20.9l22.2-19.8zM112 176a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zM504.7 500.5c6.3 7.1 16.2 9.9 25 6.2c5.3-2.3 10.5-4.8 15.5-7.6l5.4-3.1c5-3 9.9-6.3 14.6-9.8c7.6-5.7 10.1-15.7 7.1-24.7l-9.3-28.2c8.8-10.7 16-23 20.9-36.2l29.1-6.1c9.3-1.9 16.7-9.1 17.8-18.5c.8-6.7 1.2-13.5 1.2-20.4s-.4-13.7-1.2-20.4c-1.1-9.4-8.6-16.6-17.8-18.5L583.9 307c-5-13.3-12.1-25.5-20.9-36.2l9.3-28.2c3-9 .5-19-7.1-24.7c-4.7-3.5-9.6-6.8-14.6-9.9l-5.3-3c-5-2.8-10.2-5.3-15.6-7.6c-8.7-3.7-18.6-.9-25 6.2l-19.8 22.2c-6.8-1.1-13.8-1.7-20.9-1.7s-14.1 .6-20.9 1.7l-19.8-22.2c-6.3-7.1-16.2-9.9-25-6.2c-5.3 2.3-10.5 4.8-15.6 7.6l-5.2 3c-5.1 3-9.9 6.3-14.6 9.9c-7.6 5.7-10.1 15.7-7.1 24.7l9.3 28.2c-8.8 10.7-16 23-20.9 36.2L315.1 313c-9.3 1.9-16.7 9.1-17.8 18.5c-.8 6.7-1.2 13.5-1.2 20.4s.4 13.7 1.2 20.4c1.1 9.4 8.6 16.6 17.8 18.5l29.1 6.1c5 13.3 12.1 25.5 20.9 36.2l-9.3 28.2c-3 9-.5 19 7.1 24.7c4.7 3.5 9.5 6.8 14.6 9.8l5.4 3.1c5 2.8 10.2 5.3 15.5 7.6c8.7 3.7 18.6 .9 25-6.2l19.8-22.2c6.8 1.1 13.8 1.7 20.9 1.7s14.1-.6 20.9-1.7l19.8 22.2zM464 304a48 48 0 1 1 0 96 48 48 0 1 1 0-96z"
],
"compact-disc": [
512,
"M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zM88 256H56c0-105.9 86.1-192 192-192v32c-88.2 0-160 71.8-160 160zm160 96c-53 0-96-43-96-96s43-96 96-96 96 43 96 96-43 96-96 96zm0-128c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32z"
],
"copy": [
448,
"M208 0L332.1 0c12.7 0 24.9 5.1 33.9 14.1l67.9 67.9c9 9 14.1 21.2 14.1 33.9L448 336c0 26.5-21.5 48-48 48l-192 0c-26.5 0-48-21.5-48-48l0-288c0-26.5 21.5-48 48-48zM48 128l80 0 0 64-64 0 0 256 192 0 0-32 64 0 0 48c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 176c0-26.5 21.5-48 48-48z"
],
"crown": [
576,
"M309 106c11.4-7 19-19.7 19-34c0-22.1-17.9-40-40-40s-40 17.9-40 40c0 14.4 7.6 27 19 34L209.7 220.6c-9.1 18.2-32.7 23.4-48.6 10.7L72 160c5-6.7 8-15 8-24c0-22.1-17.9-40-40-40S0 113.9 0 136s17.9 40 40 40c.2 0 .5 0 .7 0L86.4 427.4c5.5 30.4 32 52.6 63 52.6l277.2 0c30.9 0 57.4-22.1 63-52.6L535.3 176c.2 0 .5 0 .7 0c22.1 0 40-17.9 40-40s-17.9-40-40-40s-40 17.9-40 40c0 9 3 17.3 8 24l-89.1 71.3c-15.9 12.7-39.5 7.5-48.6-10.7L309 106z"
],
"database": [
448,
"M448 80l0 48c0 44.2-100.3 80-224 80S0 172.2 0 128L0 80C0 35.8 100.3 0 224 0S448 35.8 448 80zM393.2 214.7c20.8-7.4 39.9-16.9 54.8-28.6L448 288c0 44.2-100.3 80-224 80S0 332.2 0 288L0 186.1c14.9 11.8 34 21.2 54.8 28.6C99.7 230.7 159.5 240 224 240s124.3-9.3 169.2-25.3zM0 346.1c14.9 11.8 34 21.2 54.8 28.6C99.7 390.7 159.5 400 224 400s124.3-9.3 169.2-25.3c20.8-7.4 39.9-16.9 54.8-28.6l0 85.9c0 44.2-100.3 80-224 80S0 476.2 0 432l0-85.9z"
],
"desktop": [
576,
"M64 0C28.7 0 0 28.7 0 64L0 352c0 35.3 28.7 64 64 64l176 0-10.7 32L160 448c-17.7 0-32 14.3-32 32s14.3 32 32 32l256 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-69.3 0L336 416l176 0c35.3 0 64-28.7 64-64l0-288c0-35.3-28.7-64-64-64L64 0zM512 64l0 224L64 288 64 64l448 0z"
],
"download": [
512,
"M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 242.7-73.4-73.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l128 128c12.5 12.5 32.8 12.5 45.3 0l128-128c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L288 274.7 288 32zM64 352c-35.3 0-64 28.7-64 64l0 32c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-32c0-35.3-28.7-64-64-64l-101.5 0-45.3 45.3c-25 25-65.5 25-90.5 0L165.5 352 64 352zm368 56a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"
],
"ellipsis-v": [
128,
"M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"
],
"envelope": [
512,
"M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48L48 64zM0 176L0 384c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-208L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z"
],
"exclamation-circle": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
],
"exclamation-triangle": [
512,
"M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
],
"expand": [
448,
"M32 32C14.3 32 0 46.3 0 64l0 96c0 17.7 14.3 32 32 32s32-14.3 32-32l0-64 64 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 32zM64 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7 14.3 32 32 32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-64 0 0-64zM320 32c-17.7 0-32 14.3-32 32s14.3 32 32 32l64 0 0 64c0 17.7 14.3 32 32 32s32-14.3 32-32l0-96c0-17.7-14.3-32-32-32l-96 0zM448 352c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 64-64 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l96 0c17.7 0 32-14.3 32-32l0-96z"
],
"external-link-alt": [
512,
"M290.4 19.8C295.4 7.8 307.1 0 320 0L480 0c17.7 0 32 14.3 32 32l0 160c0 12.9-7.8 24.6-19.8 29.6s-25.7 2.2-34.9-6.9L400 157.3 246.6 310.6c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L354.7 112 297.4 54.6c-9.2-9.2-11.9-22.9-6.9-34.9zM0 176c0-44.2 35.8-80 80-80l80 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-80 0c-8.8 0-16 7.2-16 16l0 256c0 8.8 7.2 16 16 16l256 0c8.8 0 16-7.2 16-16l0-80c0-17.7 14.3-32 32-32s32 14.3 32 32l0 80c0 44.2-35.8 80-80 80L80 512c-44.2 0-80-35.8-80-80L0 176z"
],
"eye": [
576,
"M288 32c-80.8 0-145.5 36.8-192.6 80.6C48.6 156 17.3 208 2.5 243.7c-3.3 7.9-3.3 16.7 0 24.6C17.3 304 48.6 356 95.4 399.4C142.5 443.2 207.2 480 288 480s145.5-36.8 192.6-80.6c46.8-43.5 78.1-95.4 93-131.1c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C433.5 68.8 368.8 32 288 32zM144 256a144 144 0 1 1 288 0 144 144 0 1 1 -288 0zm144-64c0 35.3-28.7 64-64 64c-7.1 0-13.9-1.2-20.3-3.3c-5.5-1.8-11.9 1.6-11.7 7.4c.3 6.9 1.3 13.8 3.2 20.7c13.7 51.2 66.4 81.6 117.6 67.9s81.6-66.4 67.9-117.6c-11.1-41.5-47.8-69.4-88.6-71.1c-5.8-.2-9.2 6.1-7.4 11.7c2.1 6.4 3.3 13.2 3.3 20.3z"
],
"file": [
384,
"M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128z"
],
"file-alt": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM112 256l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16zm0 64l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-160 0c-8.8 0-16-7.2-16-16s7.2-16 16-16z"
],
"file-archive": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm-6.3 71.8c3.7-14 16.4-23.8 30.9-23.8l14.8 0c14.5 0 27.2 9.7 30.9 23.8l23.5 88.2c1.4 5.4 2.1 10.9 2.1 16.4c0 35.2-28.8 63.7-64 63.7s-64-28.5-64-63.7c0-5.5 .7-11.1 2.1-16.4l23.5-88.2zM112 336c-8.8 0-16 7.2-16 16s7.2 16 16 16l32 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0z"
],
"file-audio": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zm2 226.3c37.1 22.4 62 63.1 62 109.7s-24.9 87.3-62 109.7c-7.6 4.6-17.4 2.1-22-5.4s-2.1-17.4 5.4-22C269.4 401.5 288 370.9 288 336s-18.6-65.5-46.5-82.3c-7.6-4.6-10-14.4-5.4-22s14.4-10 22-5.4zm-91.9 30.9c6 2.5 9.9 8.3 9.9 14.8l0 128c0 6.5-3.9 12.3-9.9 14.8s-12.9 1.1-17.4-3.5L113.4 376 80 376c-8.8 0-16-7.2-16-16l0-48c0-8.8 7.2-16 16-16l33.4 0 35.3-35.3c4.6-4.6 11.5-5.9 17.4-3.5zm51 34.9c6.6-5.9 16.7-5.3 22.6 1.3C249.8 304.6 256 319.6 256 336s-6.2 31.4-16.3 42.7c-5.9 6.6-16 7.1-22.6 1.3s-7.1-16-1.3-22.6c5.1-5.7 8.1-13.1 8.1-21.3s-3.1-15.7-8.1-21.3c-5.9-6.6-5.3-16.7 1.3-22.6z"
],
"file-code": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM153 289l-31 31 31 31c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0L71 337c-9.4-9.4-9.4-24.6 0-33.9l48-48c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9zM265 255l48 48c9.4 9.4 9.4 24.6 0 33.9l-48 48c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l31-31-31-31c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"
],
"file-excel": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM155.7 250.2L192 302.1l36.3-51.9c7.6-10.9 22.6-13.5 33.4-5.9s13.5 22.6 5.9 33.4L221.3 344l46.4 66.2c7.6 10.9 5 25.8-5.9 33.4s-25.8 5-33.4-5.9L192 385.8l-36.3 51.9c-7.6 10.9-22.6 13.5-33.4 5.9s-13.5-22.6-5.9-33.4L162.7 344l-46.4-66.2c-7.6-10.9-5-25.8 5.9-33.4s25.8-5 33.4 5.9z"
],
"file-image": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM64 256a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm152 32c5.3 0 10.2 2.6 13.2 6.9l88 128c3.4 4.9 3.7 11.3 1 16.5s-8.2 8.6-14.2 8.6l-88 0-40 0-48 0-48 0c-5.8 0-11.1-3.1-13.9-8.1s-2.8-11.2 .2-16.1l48-80c2.9-4.8 8.1-7.8 13.7-7.8s10.8 2.9 13.7 7.8l12.8 21.4 48.3-70.2c3-4.3 7.9-6.9 13.2-6.9z"
],
"file-pdf": [
512,
"M0 64C0 28.7 28.7 0 64 0L224 0l0 128c0 17.7 14.3 32 32 32l128 0 0 144-208 0c-35.3 0-64 28.7-64 64l0 144-48 0c-35.3 0-64-28.7-64-64L0 64zm384 64l-128 0L256 0 384 128zM176 352l32 0c30.9 0 56 25.1 56 56s-25.1 56-56 56l-16 0 0 32c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-48 0-80c0-8.8 7.2-16 16-16zm32 80c13.3 0 24-10.7 24-24s-10.7-24-24-24l-16 0 0 48 16 0zm96-80l32 0c26.5 0 48 21.5 48 48l0 64c0 26.5-21.5 48-48 48l-32 0c-8.8 0-16-7.2-16-16l0-128c0-8.8 7.2-16 16-16zm32 128c8.8 0 16-7.2 16-16l0-64c0-8.8-7.2-16-16-16l-16 0 0 96 16 0zm80-112c0-8.8 7.2-16 16-16l48 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0 0 32 32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0 0 48c0 8.8-7.2 16-16 16s-16-7.2-16-16l0-64 0-64z"
],
"file-powerpoint": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM136 240l68 0c42 0 76 34 76 76s-34 76-76 76l-44 0 0 32c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-56 0-104c0-13.3 10.7-24 24-24zm68 104c15.5 0 28-12.5 28-28s-12.5-28-28-28l-44 0 0 56 44 0z"
],
"file-video": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM64 288c0-17.7 14.3-32 32-32l96 0c17.7 0 32 14.3 32 32l0 96c0 17.7-14.3 32-32 32l-96 0c-17.7 0-32-14.3-32-32l0-96zM300.9 397.9L256 368l0-64 44.9-29.9c2-1.3 4.4-2.1 6.8-2.1c6.8 0 12.3 5.5 12.3 12.3l0 103.4c0 6.8-5.5 12.3-12.3 12.3c-2.4 0-4.8-.7-6.8-2.1z"
],
"file-word": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM111 257.1l26.8 89.2 31.6-90.3c3.4-9.6 12.5-16.1 22.7-16.1s19.3 6.4 22.7 16.1l31.6 90.3L273 257.1c3.8-12.7 17.2-19.9 29.9-16.1s19.9 17.2 16.1 29.9l-48 160c-3 10-12 16.9-22.4 17.1s-19.8-6.2-23.2-16.1L192 336.6l-33.3 95.3c-3.4 9.8-12.8 16.3-23.2 16.1s-19.5-7.1-22.4-17.1l-48-160c-3.8-12.7 3.4-26.1 16.1-29.9s26.1 3.4 29.9 16.1z"
],
"folder": [
512,
"M64 480H448c35.3 0 64-28.7 64-64V160c0-35.3-28.7-64-64-64H288c-10.1 0-19.6-4.7-25.6-12.8L243.2 57.6C231.1 41.5 212.1 32 192 32H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64z"
],
"folder-open": [
576,
"M88.7 223.8L0 375.8 0 96C0 60.7 28.7 32 64 32l117.5 0c17 0 33.3 6.7 45.3 18.7l26.5 26.5c12 12 28.3 18.7 45.3 18.7L416 96c35.3 0 64 28.7 64 64l0 32-336 0c-22.8 0-43.8 12.1-55.3 31.8zm27.6 16.1C122.1 230 132.6 224 144 224l400 0c11.5 0 22 6.1 27.7 16.1s5.7 22.2-.1 32.1l-112 192C453.9 474 443.4 480 432 480L32 480c-11.5 0-22-6.1-27.7-16.1s-5.7-22.2 .1-32.1l112-192z"
],
"folder-plus": [
512,
"M512 416c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96C0 60.7 28.7 32 64 32l128 0c20.1 0 39.1 9.5 51.2 25.6l19.2 25.6c6 8.1 15.5 12.8 25.6 12.8l160 0c35.3 0 64 28.7 64 64l0 256zM232 376c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64 64 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-64 0 0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 64-64 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l64 0 0 64z"
],
"forward": [
512,
"M371.7 43.1C360.1 32 343 28.9 328.3 35.2S304 56 304 72l0 136.3-172.3-165.1C120.1 32 103 28.9 88.3 35.2S64 56 64 72l0 368c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9L304 303.7 304 440c0 16 9.6 30.5 24.3 36.8s31.8 3.2 43.4-7.9l192-184c7.9-7.5 12.3-18 12.3-28.9s-4.5-21.3-12.3-28.9l-192-184z"
],
"github": [
496,
"M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
],
"globe": [
512,
"M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"
],
"grip-vertical": [
320,
"M128 40c0-22.1-17.9-40-40-40L40 0C17.9 0 0 17.9 0 40L0 88c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zm0 192c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zM0 424l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40zM320 40c0-22.1-17.9-40-40-40L232 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48zM192 232l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40zM320 424c0-22.1-17.9-40-40-40l-48 0c-22.1 0-40 17.9-40 40l0 48c0 22.1 17.9 40 40 40l48 0c22.1 0 40-17.9 40-40l0-48z"
],
"hdd": [
512,
"M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 184.4c-17-15.2-39.4-24.4-64-24.4L64 256c-24.6 0-47 9.2-64 24.4L0 96zM64 288l384 0c35.3 0 64 28.7 64 64l0 64c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64l0-64c0-35.3 28.7-64 64-64zM320 416a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm128-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
],
"home": [
640,
"M341.8 72.6C329.5 61.2 310.5 61.2 298.3 72.6L74.3 280.6C64.7 289.6 61.5 303.5 66.3 315.7C71.1 327.9 82.8 336 96 336L112 336L112 512C112 547.3 140.7 576 176 576L464 576C499.3 576 528 547.3 528 512L528 336L544 336C557.2 336 569 327.9 573.8 315.7C578.6 303.5 575.4 289.5 565.8 280.6L341.8 72.6zM304 384L336 384C362.5 384 384 405.5 384 432L384 528L256 528L256 432C256 405.5 277.5 384 304 384z"
],
"id-card": [
576,
"M0 96l576 0c0-35.3-28.7-64-64-64L64 32C28.7 32 0 60.7 0 96zm0 32L0 416c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-288L0 128zM64 405.3c0-29.5 23.9-53.3 53.3-53.3l117.3 0c29.5 0 53.3 23.9 53.3 53.3c0 5.9-4.8 10.7-10.7 10.7L74.7 416c-5.9 0-10.7-4.8-10.7-10.7zM176 192a64 64 0 1 1 0 128 64 64 0 1 1 0-128zm176 16c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l128 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-128 0c-8.8 0-16-7.2-16-16z"
],
"images": [
576,
"M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z"
],
"infinity": [
640,
"M0 256c0-88.4 71.6-160 160-160 50.4 0 97.8 23.7 128 64l32 42.7 32-42.7c30.2-40.3 77.6-64 128-64 88.4 0 160 71.6 160 160S568.4 416 480 416c-50.4 0-97.8-23.7-128-64l-32-42.7-32 42.7c-30.2 40.3-77.6 64-128 64-88.4 0-160-71.6-160-160zm280 0l-43.2-57.6c-18.1-24.2-46.6-38.4-76.8-38.4-53 0-96 43-96 96s43 96 96 96c30.2 0 58.7-14.2 76.8-38.4L280 256zm80 0l43.2 57.6c18.1 24.2 46.6 38.4 76.8 38.4 53 0 96-43 96-96s-43-96-96-96c-30.2 0-58.7 14.2-76.8 38.4L360 256z"
],
"info-circle": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336l24 0 0-64-24 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l48 0c13.3 0 24 10.7 24 24l0 88 8 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"
],
"key": [
512,
"M336 352c97.2 0 176-78.8 176-176S433.2 0 336 0S160 78.8 160 176c0 18.7 2.9 36.8 8.3 53.7L7 391c-4.5 4.5-7 10.6-7 17l0 80c0 13.3 10.7 24 24 24l80 0c13.3 0 24-10.7 24-24l0-40 40 0c13.3 0 24-10.7 24-24l0-40 40 0c6.4 0 12.5-2.5 17-7l33.3-33.3c16.9 5.4 35 8.3 53.7 8.3zM376 96a40 40 0 1 1 0 80 40 40 0 1 1 0-80z"
],
"keyboard": [
576,
"M64 64C28.7 64 0 92.7 0 128L0 384c0 35.3 28.7 64 64 64l448 0c35.3 0 64-28.7 64-64l0-256c0-35.3-28.7-64-64-64L64 64zm16 64l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM64 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zm80-176c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM160 336c0-8.8 7.2-16 16-16l224 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-224 0c-8.8 0-16-7.2-16-16l0-32zM272 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM256 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM368 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM352 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zM464 128l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16zM448 240c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32zm16 80l32 0c8.8 0 16 7.2 16 16l0 32c0 8.8-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16l0-32c0-8.8 7.2-16 16-16z"
],
"layer-group": [
512,
"M232.5 5.2c14.9-6.9 32.1-6.9 47 0l218.6 101c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 149.8C5.4 145.8 0 137.3 0 128s5.4-17.9 13.9-21.8L232.5 5.2zM48.1 218.4l164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 277.8C5.4 273.8 0 265.3 0 256s5.4-17.9 13.9-21.8l34.1-15.8zM13.9 362.2l34.1-15.8 164.3 75.9c27.7 12.8 59.6 12.8 87.3 0l164.3-75.9 34.1 15.8c8.5 3.9 13.9 12.4 13.9 21.8s-5.4 17.9-13.9 21.8l-218.6 101c-14.9 6.9-32.1 6.9-47 0L13.9 405.8C5.4 401.8 0 393.3 0 384s5.4-17.9 13.9-21.8z"
],
"level-up-alt": [
384,
"M169.4 9.4c12.5-12.5 32.8-12.5 45.3 0l128 128c9.2 9.2 11.9 22.9 6.9 34.9S332.9 192 320 192l-64 0 0 160c0 88.4-71.6 160-160 160l-64 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l64 0c17.7 0 32-14.3 32-32l0-160-64 0c-12.9 0-24.6-7.8-29.6-19.8s-2.2-25.7 6.9-34.9l128-128z"
],
"link": [
576,
"M419.5 96c-16.6 0-32.7 4.5-46.8 12.7-15.8-16-34.2-29.4-54.5-39.5 28.2-24 64.1-37.2 101.3-37.2 86.4 0 156.5 70 156.5 156.5 0 41.5-16.5 81.3-45.8 110.6l-71.1 71.1c-29.3 29.3-69.1 45.8-110.6 45.8-86.4 0-156.5-70-156.5-156.5 0-1.5 0-3 .1-4.5 .5-17.7 15.2-31.6 32.9-31.1s31.6 15.2 31.1 32.9c0 .9 0 1.8 0 2.6 0 51.1 41.4 92.5 92.5 92.5 24.5 0 48-9.7 65.4-27.1l71.1-71.1c17.3-17.3 27.1-40.9 27.1-65.4 0-51.1-41.4-92.5-92.5-92.5zM275.2 173.3c-1.9-.8-3.8-1.9-5.5-3.1-12.6-6.5-27-10.2-42.1-10.2-24.5 0-48 9.7-65.4 27.1L91.1 258.2c-17.3 17.3-27.1 40.9-27.1 65.4 0 51.1 41.4 92.5 92.5 92.5 16.5 0 32.6-4.4 46.7-12.6 15.8 16 34.2 29.4 54.6 39.5-28.2 23.9-64 37.2-101.3 37.2-86.4 0-156.5-70-156.5-156.5 0-41.5 16.5-81.3 45.8-110.6l71.1-71.1c29.3-29.3 69.1-45.8 110.6-45.8 86.6 0 156.5 70.6 156.5 156.9 0 1.3 0 2.6 0 3.9-.4 17.7-15.1 31.6-32.8 31.2s-31.6-15.1-31.2-32.8c0-.8 0-1.5 0-2.3 0-33.7-18-63.3-44.8-79.6z"
],
"list": [
512,
"M40 48C26.7 48 16 58.7 16 72l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24L40 48zM192 64c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L192 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l288 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-288 0zM16 232l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0c-13.3 0-24 10.7-24 24zM40 368c-13.3 0-24 10.7-24 24l0 48c0 13.3 10.7 24 24 24l48 0c13.3 0 24-10.7 24-24l0-48c0-13.3-10.7-24-24-24l-48 0z"
],
"location-crosshairs": [
576,
"M288-16c17.7 0 32 14.3 32 32l0 18.3c98.1 14 175.7 91.6 189.7 189.7l18.3 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-18.3 0c-14 98.1-91.6 175.7-189.7 189.7l0 18.3c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-18.3C157.9 463.7 80.3 386.1 66.3 288L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l18.3 0C80.3 125.9 157.9 48.3 256 34.3L256 16c0-17.7 14.3-32 32-32zM128 256a160 160 0 1 0 320 0 160 160 0 1 0 -320 0zm160-96a96 96 0 1 1 0 192 96 96 0 1 1 0-192z"
],
"lock": [
448,
"M144 144l0 48 160 0 0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80zM80 192l0-48C80 64.5 144.5 0 224 0s144 64.5 144 144l0 48 16 0c35.3 0 64 28.7 64 64l0 192c0 35.3-28.7 64-64 64L64 512c-35.3 0-64-28.7-64-64L0 256c0-35.3 28.7-64 64-64l16 0z"
],
"moon": [
384,
"M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z"
],
"music": [
512,
"M468 7c7.6 6.1 12 15.3 12 25l0 304c0 44.2-43 80-96 80s-96-35.8-96-80 43-80 96-80c11.2 0 22 1.6 32 4.6l0-116.7-224 49.8 0 206.3c0 44.2-43 80-96 80s-96-35.8-96-80 43-80 96-80c11.2 0 22 1.6 32 4.6L128 96c0-15 10.4-28 25.1-31.2l288-64c9.5-2.1 19.4 .2 27 6.3z"
],
"oxiexport": [
576,
"M384.5 24l0 72-64 0c-79.5 0-144 64.5-144 144 0 93.4 82.8 134.8 100.6 142.6 2.2 1 4.6 1.4 7.1 1.4l2.5 0c9.8 0 17.8-8 17.8-17.8 0-8.3-5.9-15.5-12.8-20.3-8.9-6.2-19.2-18.2-19.2-40.5 0-45 36.5-81.5 81.5-81.5l30.5 0 0 72c0 9.7 5.8 18.5 14.8 22.2s19.3 1.7 26.2-5.2l136-136c9.4-9.4 9.4-24.6 0-33.9L425.5 7c-6.9-6.9-17.2-8.9-26.2-5.2S384.5 14.3 384.5 24zm-272 72c-44.2 0-80 35.8-80 80l0 256c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32c0 8.8-7.2 16-16 16l-256 0c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l16 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-16 0z"
],
"oxiimport": [
576,
"m 360.55,24 v 72 h 64 c 79.5,0 144,64.5 144,144 0,93.4 -82.8,134.8 -100.6,142.6 -2.2,1 -4.6,1.4 -7.1,1.4 h -2.5 c -9.8,0 -17.8,-8 -17.8,-17.8 0,-8.3 5.9,-15.5 12.8,-20.3 8.9,-6.2 19.2,-18.2 19.2,-40.5 0,-45 -36.5,-81.5 -81.5,-81.5 h -30.5 v 72 c 0,9.7 -5.8,18.5 -14.8,22.2 -9,3.7 -19.3,1.7 -26.2,-5.2 l -136,-136 c -9.4,-9.4 -9.4,-24.6 0,-33.9 l 136,-136 c 6.9,-6.9 17.2,-8.9 26.2,-5.2 9,3.7 14.8,12.5 14.8,22.2 z M 112.5,96 c -44.2,0 -80,35.8 -80,80 v 256 c 0,44.2 35.8,80 80,80 h 256 c 44.2,0 80,-35.8 80,-80 v -32 c 0,-17.7 -14.3,-32 -32,-32 -17.7,0 -32,14.3 -32,32 v 32 c 0,8.8 -7.2,16 -16,16 h -256 c -8.8,0 -16,-7.2 -16,-16 V 176 c 0,-8.8 7.2,-16 16,-16 h 16 c 17.7,0 32,-14.3 32,-32 0,-17.7 -14.3,-32 -32,-32 z"
],
"paper-plane": [
576,
"M290.5 287.7L491.4 86.9 359 456.3 290.5 287.7zM457.4 53L256.6 253.8 88 185.3 457.4 53zM38.1 216.8l205.8 83.6 83.6 205.8c5.3 13.1 18.1 21.7 32.3 21.7 14.7 0 27.8-9.2 32.8-23.1L570.6 8c3.5-9.8 1-20.6-6.3-28s-18.2-9.8-28-6.3L39.4 151.7c-13.9 5-23.1 18.1-23.1 32.8 0 14.2 8.6 27 21.7 32.3z"
],
"pause": [
384,
"M48 32C21.5 32 0 53.5 0 80L0 432c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48L48 32zm224 0c-26.5 0-48 21.5-48 48l0 352c0 26.5 21.5 48 48 48l64 0c26.5 0 48-21.5 48-48l0-352c0-26.5-21.5-48-48-48l-64 0z"
],
"pen": [
512,
"M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"
],
"pencil-alt": [
512,
"M36.4 353.2c4.1-14.6 11.8-27.9 22.6-38.7l181.2-181.2 33.9-33.9c16.6 16.6 51.3 51.3 104 104l33.9 33.9-33.9 33.9-181.2 181.2c-10.7 10.7-24.1 18.5-38.7 22.6L30.4 510.6c-8.3 2.3-17.3 0-23.4-6.2S-1.4 489.3 .9 481L36.4 353.2zm55.6-3.7c-4.4 4.7-7.6 10.4-9.3 16.6l-24.1 86.9 86.9-24.1c6.4-1.8 12.2-5.1 17-9.7L91.9 349.5zm354-146.1c-16.6-16.6-51.3-51.3-104-104L308 65.5C334.5 39 349.4 24.1 352.9 20.6 366.4 7 384.8-.6 404-.6S441.6 7 455.1 20.6l35.7 35.7C504.4 69.9 512 88.3 512 107.4s-7.6 37.6-21.2 51.1c-3.5 3.5-18.4 18.4-44.9 44.9z"
],
"people-roof": [
576,
"M302.3-12.6c-9-4.5-19.6-4.5-28.6 0l-256 128C1.9 123.3-4.5 142.5 3.4 158.3s27.1 22.2 42.9 14.3L288 51.8 529.7 172.6c15.8 7.9 35 1.5 42.9-14.3s1.5-35-14.3-42.9l-256-128zM288 272a56 56 0 1 0 0-112 56 56 0 1 0 0 112zm0 48c-53 0-96 43-96 96l0 32c0 17.7 14.3 32 32 32l128 0c17.7 0 32-14.3 32-32l0-32c0-53-43-96-96-96zM160 256a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm352 0a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM112 336c-44.2 0-80 35.8-80 80l0 33.1c0 17 13.8 30.9 30.9 30.9l87.8 0c-4.3-9.8-6.7-20.6-6.7-32l0-48c0-18.4 3.5-36 9.8-52.2-12.2-7.5-26.5-11.8-41.8-11.8zM425.4 480l87.8 0c17 0 30.9-13.8 30.9-30.9l0-33.1c0-44.2-35.8-80-80-80-15.3 0-29.6 4.3-41.8 11.8 6.3 16.2 9.8 33.8 9.8 52.2l0 48c0 11.4-2.4 22.2-6.7 32z"
],
"play": [
384,
"M73 39c-14.8-9.1-33.4-9.4-48.5-.9S0 62.6 0 80L0 432c0 17.4 9.4 33.4 24.5 41.9s33.7 8.1 48.5-.9L361 297c14.3-8.7 23-24.2 23-41s-8.7-32.2-23-41L73 39z"
],
"plus": [
448,
"M256 64c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 160-160 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l160 0 0 160c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160 160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-160 0 0-160z"
],
"question-circle": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM169.8 165.3c7.9-22.3 29.1-37.3 52.8-37.3l58.3 0c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24l0-13.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1l-58.3 0c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
],
"repeat": [
512,
"M470.6 118.6c12.5-12.5 12.5-32.8 0-45.3l-64-64c-9.2-9.2-22.9-11.9-34.9-6.9S352 19.1 352 32l0 32-160 0C86 64 0 150 0 256 0 273.7 14.3 288 32 288s32-14.3 32-32c0-70.7 57.3-128 128-128l160 0 0 32c0 12.9 7.8 24.6 19.8 29.6s25.7 2.2 34.9-6.9l64-64zM41.4 393.4c-12.5 12.5-12.5 32.8 0 45.3l64 64c9.2 9.2 22.9 11.9 34.9 6.9S160 492.9 160 480l0-32 160 0c106 0 192-86 192-192 0-17.7-14.3-32-32-32s-32 14.3-32 32c0 70.7-57.3 128-128 128l-160 0 0-32c0-12.9-7.8-24.6-19.8-29.6s-25.7-2.2-34.9 6.9l-64 64z"
],
"save": [
448,
"M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l320 0c35.3 0 64-28.7 64-64l0-242.7c0-17-6.7-33.3-18.7-45.3L352 50.7C340 38.7 323.7 32 306.7 32L64 32zm0 96c0-17.7 14.3-32 32-32l192 0c17.7 0 32 14.3 32 32l0 64c0 17.7-14.3 32-32 32L96 224c-17.7 0-32-14.3-32-32l0-64zM224 288a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"
],
"search": [
512,
"M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"
],
"search-minus": [
512,
"M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM136 184c-13.3 0-24 10.7-24 24s10.7 24 24 24l144 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-144 0z"
],
"search-plus": [
512,
"M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM184 296c0 13.3 10.7 24 24 24s24-10.7 24-24l0-64 64 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-64 0 0-64c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 64-64 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l64 0 0 64z"
],
"server": [
512,
"M64 32C28.7 32 0 60.7 0 96l0 64c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 32zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm48 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zM64 288c-35.3 0-64 28.7-64 64l0 64c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-64c0-35.3-28.7-64-64-64L64 288zm280 72a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm56 24a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"
],
"share-alt": [
448,
"M352 224c53 0 96-43 96-96s-43-96-96-96s-96 43-96 96c0 4 .2 8 .7 11.9l-94.1 47C145.4 170.2 121.9 160 96 160c-53 0-96 43-96 96s43 96 96 96c25.9 0 49.4-10.2 66.6-26.9l94.1 47c-.5 3.9-.7 7.8-.7 11.9c0 53 43 96 96 96s96-43 96-96s-43-96-96-96c-25.9 0-49.4 10.2-66.6 26.9l-94.1-47c.5-3.9 .7-7.8 .7-11.9s-.2-8-.7-11.9l94.1-47C302.6 213.8 326.1 224 352 224z"
],
"shield-alt": [
512,
"M256 0c4.6 0 9.2 1 13.4 2.9L457.7 82.8c22 9.3 38.4 31 38.3 57.2c-.5 99.2-41.3 280.7-213.6 363.2c-16.7 8-36.1 8-52.8 0C57.3 420.7 16.5 239.2 16 140c-.1-26.2 16.3-47.9 38.3-57.2L242.7 2.9C246.8 1 251.4 0 256 0zm0 66.8l0 378.1C394 378 431.1 230.1 432 141.4L256 66.8s0 0 0 0z"
],
"shuffle": [
512,
"M403.8 34.4c12-5 25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64c-9.2 9.2-22.9 11.9-34.9 6.9S384 204.9 384 192l0-32-32 0c-10.1 0-19.6 4.7-25.6 12.8l-32.4 43.2-40-53.3 21.2-28.3C293.3 110.2 321.8 96 352 96l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6zM154 296l40 53.3-21.2 28.3C154.7 401.8 126.2 416 96 416l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c10.1 0 19.6-4.7 25.6-12.8L154 296zM438.6 470.6c-9.2 9.2-22.9 11.9-34.9 6.9S384 460.9 384 448l0-32-32 0c-30.2 0-58.7-14.2-76.8-38.4L121.6 172.8c-6-8.1-15.5-12.8-25.6-12.8l-64 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l64 0c30.2 0 58.7 14.2 76.8 38.4L326.4 339.2c6 8.1 15.5 12.8 25.6 12.8l32 0 0-32c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l64 64c6 6 9.4 14.1 9.4 22.6s-3.4 16.6-9.4 22.6l-64 64z"
],
"sign-in-alt": [
512,
"M217.9 105.9L340.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L217.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1L32 320c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM352 416l64 0c17.7 0 32-14.3 32-32l0-256c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32l64 0c53 0 96 43 96 96l0 256c0 53-43 96-96 96l-64 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"
],
"sign-out-alt": [
512,
"M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"
],
"sliders-h": [
512,
"M0 416c0 17.7 14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384c-17.7 0-32 14.3-32 32zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM320 256a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm32-80c-32.8 0-61 19.7-73.3 48L32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48zM192 128a32 32 0 1 1 0-64 32 32 0 1 1 0 64zm73.3-64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64z"
],
"spinner": [
512,
"M304 48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zm0 416a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM48 304a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm464-48a48 48 0 1 0 -96 0 48 48 0 1 0 96 0zM142.9 437A48 48 0 1 0 75 369.1 48 48 0 1 0 142.9 437zm0-294.2A48 48 0 1 0 75 75a48 48 0 1 0 67.9 67.9zM369.1 437A48 48 0 1 0 437 369.1 48 48 0 1 0 369.1 437z"
],
"star": [
576,
"M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z"
],
"star-outline": [
576,
"M287.9 0c9.2 0 17.6 5.2 21.6 13.5l68.6 141.3 153.2 22.6c9 1.3 16.5 7.6 19.3 16.3s.5 18.1-5.9 24.5L439.6 319.9l24.6 145.7c1.5 9-2.2 18.1-9.7 23.5s-17.3 6-25.3 1.7l-137-73.2L155.2 490.8c-8 4.3-17.8 3.7-25.3-1.7s-11.2-14.5-9.7-23.5l24.6-145.7L39.6 218.2c-6.4-6.4-8.7-15.9-5.9-24.5s10.3-14.9 19.3-16.3l153.2-22.6L274.3 13.5C278.3 5.2 286.7 0 295.9 0h-8zm0 79L235.4 187.2c-3.5 7.1-10.2 12.1-18.1 13.3L99 218.9l85.8 85.1c5.5 5.5 8.1 13.3 6.8 21L171.3 444.7l111.5-59.5c7-3.7 15.3-3.7 22.3 0l111.5 59.5-20.3-119.7c-1.3-7.7 1.2-15.5 6.8-21l85.8-85.1-118.3-17.4c-7.8-1.2-14.6-6.1-18.1-13.3L287.9 79z"
],
"sun": [
512,
"M375.7 19.7c-1.5-8-6.9-14.7-14.4-17.8s-16.1-2.2-22.8 2.4L256 61 173.5 4.2c-6.7-4.6-15.3-5.5-22.8-2.4s-12.9 9.8-14.4 17.8l-18.1 98.5L19.7 136.3c-8 1.5-14.7 6.9-17.8 14.4s-2.2 16.1 2.4 22.8L61 256 4.2 338.5c-4.6 6.7-5.5 15.3-2.4 22.8s9.8 13 17.8 14.4l98.5 18.1 18.1 98.5c1.5 8 6.9 14.7 14.4 17.8s16.1 2.2 22.8-2.4L256 451l82.5 56.8c6.7 4.6 15.3 5.5 22.8 2.4s12.9-9.8 14.4-17.8l18.1-98.5 98.5-18.1c8-1.5 14.7-6.9 17.8-14.4s2.2-16.1-2.4-22.8L451 256l56.8-82.5c4.6-6.7 5.5-15.3 2.4-22.8s-9.8-12.9-17.8-14.4l-98.5-18.1L375.7 19.7zM269.6 110l65.6-45.2 14.4 78.3c1.8 9.8 9.5 17.5 19.3 19.3l78.3 14.4L402 242.4c-5.7 8.2-5.7 19 0 27.2l45.2 65.6-78.3 14.4c-9.8 1.8-17.5 9.5-19.3 19.3l-14.4 78.3L269.6 402c-8.2-5.7-19-5.7-27.2 0l-65.6 45.2-14.4-78.3c-1.8-9.8-9.5-17.5-19.3-19.3L64.8 335.2 110 269.6c5.7-8.2 5.7-19 0-27.2L64.8 176.8l78.3-14.4c9.8-1.8 17.5-9.5 19.3-19.3l14.4-78.3L242.4 110c8.2 5.7 19 5.7 27.2 0zM256 368a112 112 0 1 0 0-224 112 112 0 1 0 0 224zM192 256a64 64 0 1 1 128 0 64 64 0 1 1 -128 0z"
],
"terminal": [
576,
"M9.4 86.6C-3.1 74.1-3.1 53.9 9.4 41.4s32.8-12.5 45.3 0l192 192c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L178.7 256 9.4 86.6zM256 416l288 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-288 0c-17.7 0-32-14.3-32-32s14.3-32 32-32z"
],
"th": [
512,
"M64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm88 64l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0zM64 224l88 0 0 64-88 0 0-64zm232 0l0 64-88 0 0-64 88 0zm64 0l88 0 0 64-88 0 0-64zM152 352l0 64-88 0 0-64 88 0zm56 0l88 0 0 64-88 0 0-64zm240 0l0 64-88 0 0-64 88 0z"
],
"times": [
384,
"M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
],
"times-circle": [
512,
"M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"
],
"trash": [
448,
"M135.2 17.7C140.6 6.8 151.7 0 163.8 0L284.2 0c12.1 0 23.2 6.8 28.6 17.7L320 32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 7.2-14.3zM32 128l384 0 0 320c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-320zm96 64c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16z"
],
"trash-alt": [
448,
"M135.2 17.7C140.6 6.8 151.7 0 163.8 0L284.2 0c12.1 0 23.2 6.8 28.6 17.7L320 32l96 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 96C14.3 96 0 81.7 0 64S14.3 32 32 32l96 0 7.2-14.3zM32 128l384 0 0 320c0 35.3-28.7 64-64 64L96 512c-35.3 0-64-28.7-64-64l0-320zm96 64c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16zm96 0c-8.8 0-16 7.2-16 16l0 224c0 8.8 7.2 16 16 16s16-7.2 16-16l0-224c0-8.8-7.2-16-16-16z"
],
"undo": [
512,
"M48.5 224L40 224c-13.3 0-24-10.7-24-24L16 72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8L48.5 224z"
],
"user": [
448,
"M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l388.6 0c16.4 0 29.7-13.3 29.7-29.7C448 383.8 368.2 304 269.7 304l-91.4 0z"
],
"user-circle": [
512,
"M399 384.2C376.9 345.8 335.4 320 288 320l-64 0c-47.4 0-88.9 25.8-111 64.2c35.2 39.2 86.2 63.8 143 63.8s107.8-24.7 143-63.8zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm256 16a72 72 0 1 0 0-144 72 72 0 1 0 0 144z"
],
"user-group": [
640,
"M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM609.3 512l-137.8 0c5.4-9.4 8.6-20.3 8.6-32l0-8c0-60.7-27.1-115.2-69.8-151.8c2.4-.1 4.7-.2 7.1-.2l61.4 0C567.8 320 640 392.2 640 481.3c0 17-13.8 30.7-30.7 30.7zM432 256c-31 0-59-12.6-79.3-32.9C372.4 196.5 384 163.6 384 128c0-26.8-6.6-52.1-18.3-74.3C384.3 40.1 407.2 32 432 32c61.9 0 112 50.1 112 112s-50.1 112-112 112z"
],
"user-plus": [
640,
"M96 128a128 128 0 1 1 256 0A128 128 0 1 1 96 128zM0 482.3C0 383.8 79.8 304 178.3 304l91.4 0C368.2 304 448 383.8 448 482.3c0 16.4-13.3 29.7-29.7 29.7L29.7 512C13.3 512 0 498.7 0 482.3zM504 312l0-64-64 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l64 0 0-64c0-13.3 10.7-24 24-24s24 10.7 24 24l0 64 64 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-64 0 0 64c0 13.3-10.7 24-24 24s-24-10.7-24-24z"
],
"user-xmark": [
576,
"M254.1 304c98.5 0 178.3 79.8 178.3 178.3 0 16.4-13.3 29.7-29.7 29.7L46.1 512c-16.4 0-29.7-13.3-29.7-29.7 0-98.5 79.8-178.3 178.3-178.3l59.4 0zM530.3 108.1c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-33.9 33.9 33.9 33.9c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-33.9-33.9-33.9 33.9c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l33.9-33.9-33.9-33.9c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l33.9 33.9 33.9-33.9zM224.4 248a120 120 0 1 1 0-240 120 120 0 1 1 0 240z"
],
"users": [
640,
"M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192l42.7 0c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0L21.3 320C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7l42.7 0C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3l-213.3 0zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352l117.3 0C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7l-330.7 0c-14.7 0-26.7-11.9-26.7-26.7z"
],
"users-cog": [
640,
"M144 160A80 80 0 1 0 144 0a80 80 0 1 0 0 160zm368 0A80 80 0 1 0 512 0a80 80 0 1 0 0 160zM0 298.7C0 310.4 9.6 320 21.3 320l213.3 0c.2 0 .4 0 .7 0c-26.6-23.5-43.3-57.8-43.3-96c0-7.6 .7-15 1.9-22.3c-13.6-6.3-28.7-9.7-44.6-9.7l-42.7 0C47.8 192 0 239.8 0 298.7zM320 320c24 0 45.9-8.8 62.7-23.3c2.5-3.7 5.2-7.3 8-10.7c2.7-3.3 5.7-6.1 9-8.3C410 262.3 416 243.9 416 224c0-53-43-96-96-96s-96 43-96 96s43 96 96 96zm65.4 60.2c-10.3-5.9-18.1-16.2-20.8-28.2l-103.2 0C187.7 352 128 411.7 128 485.3c0 14.7 11.9 26.7 26.7 26.7l300.6 0c-2.1-5.2-3.2-10.9-3.2-16.4l0-3c-1.3-.7-2.7-1.5-4-2.3l-2.6 1.5c-16.8 9.7-40.5 8-54.7-9.7c-4.5-5.6-8.6-11.5-12.4-17.6l-.1-.2-.1-.2-2.4-4.1-.1-.2-.1-.2c-3.4-6.2-6.4-12.6-9-19.3c-8.2-21.2 2.2-42.6 19-52.3l2.7-1.5c0-.8 0-1.5 0-2.3s0-1.5 0-2.3l-2.7-1.5zM533.3 192l-42.7 0c-15.9 0-31 3.5-44.6 9.7c1.3 7.2 1.9 14.7 1.9 22.3c0 17.4-3.5 33.9-9.7 49c2.5 .9 4.9 2 7.1 3.3l2.6 1.5c1.3-.8 2.6-1.6 4-2.3l0-3c0-19.4 13.3-39.1 35.8-42.6c7.9-1.2 16-1.9 24.2-1.9s16.3 .6 24.2 1.9c22.5 3.5 35.8 23.2 35.8 42.6l0 3c1.3 .7 2.7 1.5 4 2.3l2.6-1.5c16.8-9.7 40.5-8 54.7 9.7c2.3 2.8 4.5 5.8 6.6 8.7c-2.1-57.1-49-102.7-106.6-102.7zm91.3 163.9c6.3-3.6 9.5-11.1 6.8-18c-2.1-5.5-4.6-10.8-7.4-15.9l-2.3-4c-3.1-5.1-6.5-9.9-10.2-14.5c-4.6-5.7-12.7-6.7-19-3l-2.9 1.7c-9.2 5.3-20.4 4-29.6-1.3s-16.1-14.5-16.1-25.1l0-3.4c0-7.3-4.9-13.8-12.1-14.9c-6.5-1-13.1-1.5-19.9-1.5s-13.4 .5-19.9 1.5c-7.2 1.1-12.1 7.6-12.1 14.9l0 3.4c0 10.6-6.9 19.8-16.1 25.1s-20.4 6.6-29.6 1.3l-2.9-1.7c-6.3-3.6-14.4-2.6-19 3c-3.7 4.6-7.1 9.5-10.2 14.6l-2.3 3.9c-2.8 5.1-5.3 10.4-7.4 15.9c-2.6 6.8 .5 14.3 6.8 17.9l2.9 1.7c9.2 5.3 13.7 15.8 13.7 26.4s-4.5 21.1-13.7 26.4l-3 1.7c-6.3 3.6-9.5 11.1-6.8 17.9c2.1 5.5 4.6 10.7 7.4 15.8l2.4 4.1c3 5.1 6.4 9.9 10.1 14.5c4.6 5.7 12.7 6.7 19 3l2.9-1.7c9.2-5.3 20.4-4 29.6 1.3s16.1 14.5 16.1 25.1l0 3.4c0 7.3 4.9 13.8 12.1 14.9c6.5 1 13.1 1.5 19.9 1.5s13.4-.5 19.9-1.5c7.2-1.1 12.1-7.6 12.1-14.9l0-3.4c0-10.6 6.9-19.8 16.1-25.1s20.4-6.6 29.6-1.3l2.9 1.7c6.3 3.6 14.4 2.6 19-3c3.7-4.6 7.1-9.4 10.1-14.5l2.4-4.2c2.8-5.1 5.3-10.3 7.4-15.8c2.6-6.8-.5-14.3-6.8-17.9l-3-1.7c-9.2-5.3-13.7-15.8-13.7-26.4s4.5-21.1 13.7-26.4l3-1.7zM472 384a40 40 0 1 1 80 0 40 40 0 1 1 -80 0z"
],
"vial": [
512,
"M342.6 9.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l9.4 9.4L28.1 342.6C10.1 360.6 0 385 0 410.5L0 416c0 53 43 96 96 96l5.5 0c25.5 0 49.9-10.1 67.9-28.1L448 205.3l9.4 9.4c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-32-32-96-96-32-32zM205.3 256L352 109.3 402.7 160l-96 96-101.5 0z"
],
"volume": [
512,
"M48 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L96 160 48 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48zM441.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C443.3 170.7 464 210.9 464 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C361.1 227.6 368 241 368 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C402.1 312.9 416 286.1 416 256s-13.9-56.9-35.5-74.5z"
],
"volume-up": [
640,
"M533.6 32.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C557.5 113.8 592 180.8 592 256s-34.5 142.2-88.7 186.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C598.5 426.7 640 346.2 640 256S598.5 85.2 533.6 32.5zM473.1 107c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C475.3 170.7 496 210.9 496 256s-20.7 85.3-53.2 111.8c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5c43.2-35.2 70.9-88.9 70.9-149s-27.7-113.8-70.9-149zm-60.5 74.5c-10.3-8.4-25.4-6.8-33.8 3.5s-6.8 25.4 3.5 33.8C393.1 227.6 400 241 400 256s-6.9 28.4-17.7 37.3c-10.3 8.4-11.8 23.5-3.5 33.8s23.5 11.8 33.8 3.5C434.1 312.9 448 286.1 448 256s-13.9-56.9-35.4-74.5zM80 352l48 0 134.1 119.2c6.4 5.7 14.6 8.8 23.1 8.8 19.2 0 34.8-15.6 34.8-34.8l0-378.4c0-19.2-15.6-34.8-34.8-34.8-8.5 0-16.7 3.1-23.1 8.8L128 160 80 160c-26.5 0-48 21.5-48 48l0 96c0 26.5 21.5 48 48 48z"
],
"world": [
512,
"M351.9 280l-190.9 0c2.9 64.5 17.2 123.9 37.5 167.4 11.4 24.5 23.7 41.8 35.1 52.4 11.2 10.5 18.9 12.2 22.9 12.2s11.7-1.7 22.9-12.2c11.4-10.6 23.7-28 35.1-52.4 20.3-43.5 34.6-102.9 37.5-167.4zM160.9 232l190.9 0C349 167.5 334.7 108.1 314.4 64.6 303 40.2 290.7 22.8 279.3 12.2 268.1 1.7 260.4 0 256.4 0s-11.7 1.7-22.9 12.2c-11.4 10.6-23.7 28-35.1 52.4-20.3 43.5-34.6 102.9-37.5 167.4zm-48 0C116.4 146.4 138.5 66.9 170.8 14.7 78.7 47.3 10.9 131.2 1.5 232l111.4 0zM1.5 280c9.4 100.8 77.2 184.7 169.3 217.3-32.3-52.2-54.4-131.7-57.9-217.3L1.5 280zm398.4 0c-3.5 85.6-25.6 165.1-57.9 217.3 92.1-32.7 159.9-116.5 169.3-217.3l-111.4 0zm111.4-48C501.9 131.2 434.1 47.3 342 14.7 374.3 66.9 396.4 146.4 399.9 232l111.4 0z"
],
"exchange-alt": [
512,
"M502.6 150.6l-96 96c-9.2 9.2-22.9 11.9-34.9 6.9S352 236.9 352 224l0-64-320 0c-17.7 0-32-14.3-32-32S14.3 96 32 96l320 0 0-64c0-12.9 7.8-24.6 19.8-29.6s25.7-2.2 34.9 6.9l96 96c12.5 12.5 12.5 32.8 0 45.3zm-397.3 352l-96-96c-12.5-12.5-12.5-32.8 0-45.3l96-96c9.2-9.2 22.9-11.9 34.9-6.9S160 275.1 160 288l0 64 320 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-320 0 0 64c0 12.9-7.8 24.6-19.8 29.6s-25.7 2.2-34.9-6.9z"
],
"flag-checkered": [
448,
"M32 0C49.7 0 64 14.3 64 32l0 16 69-17.2c38.1-9.5 78.3-5.1 113.5 12.5 46.3 23.2 100.8 23.2 147.1 0l9.6-4.8C423.8 28.1 448 43.1 448 66.1l0 279.7c0 13.3-8.3 25.3-20.8 30l-34.7 13c-46.2 17.3-97.6 14.6-141.7-7.4-37.9-19-81.4-23.7-122.5-13.4L64 384 64 480c0 17.7-14.3 32-32 32S0 497.7 0 480L0 32C0 14.3 14.3 0 32 0zM64 187.1l64-13.9 0 65.5-64 13.9 0 65.5 48.8-12.2c5.1-1.3 10.1-2.4 15.2-3.3l0-63.9 38.9-8.4c8.3-1.8 16.7-2.5 25.1-2.1l0-64c13.6 .4 27.2 2.6 40.4 6.4l23.6 6.9 0 66.7-41.7-12.3c-7.3-2.1-14.8-3.4-22.3-3.8l0 71.4c21.8 1.9 43.3 6.7 64 14.4l0-69.8 22.7 6.7c13.5 4 27.3 6.4 41.3 7.4l0-64.2c-7.8-.8-15.6-2.3-23.2-4.5l-40.8-12 0-62c-13-3.8-25.8-8.8-38.2-15-8.2-4.1-16.9-7-25.8-8.8l0 72.4c-13-.4-26 .8-38.7 3.6l-25.3 5.5 0-75.2-64 16 0 73.1zM320 335.7c16.8 1.5 33.9-.7 50-6.8l14-5.2 0-71.7-7.9 1.8c-18.4 4.3-37.3 5.7-56.1 4.5l0 77.4zm64-149.4l0-70.8c-20.9 6.1-42.4 9.1-64 9.1l0 69.4c13.9 1.4 28 .5 41.7-2.6l22.3-5.2z"
],
"id-badge": [
384,
"M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-384c0-35.3-28.7-64-64-64L64 0zm96 352l64 0c44.2 0 80 35.8 80 80 0 8.8-7.2 16-16 16L96 448c-8.8 0-16-7.2-16-16 0-44.2 35.8-80 80-80zm-24-96a56 56 0 1 1 112 0 56 56 0 1 1 -112 0zM152 64l80 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-80 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
],
"lock-open": [
576,
"M384 96c0-35.3 28.7-64 64-64s64 28.7 64 64l0 32c0 17.7 14.3 32 32 32s32-14.3 32-32l0-32c0-70.7-57.3-128-128-128S320 25.3 320 96l0 64-160 0c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64l-32 0 0-64z"
],
"adjust": [
512,
"M448 256c0-106-86-192-192-192l0 384c106 0 192-86 192-192zM0 256a256 256 0 1 1 512 0 256 256 0 1 1 -512 0z"
]
};
export type IconName = keyof typeof OxiIcons;
+115
View File
@@ -0,0 +1,115 @@
/**
* 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.
*/
import { errorMessage } from '$lib/utils/errors';
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 = errorMessage(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);
+107
View File
@@ -0,0 +1,107 @@
/**
* 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';
// Re-exported so the files view's grouping-helper barrel stays a single import
// site; the implementation lives in the shared time util.
export { relativeTimeAgo } from '$lib/utils/time';
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());
}
/** 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'
| 'shared-with-me'
| 'recent'
| 'favorites'
| 'trash'
| 'photos'
| 'music';
const VIEW_KEY = 'oxicloud_view_mode';
function readViewMode(): ViewMode {
if (typeof localStorage === 'undefined') return 'grid';
return localStorage.getItem(VIEW_KEY) === 'list' ? 'list' : 'grid';
}
class FilesStore {
currentFolder = $state<string | null>(null);
currentFolderInfo = $state<FolderItem | null>(null);
breadcrumbPath = $state<Array<{ id: string; name: string }>>([]);
viewMode = $state<ViewMode>(readViewMode());
section = $state<Section>('files');
isSearchMode = $state(false);
selection = $state<Set<string>>(new Set());
setViewMode(mode: ViewMode): void {
this.viewMode = mode;
if (typeof localStorage !== 'undefined') localStorage.setItem(VIEW_KEY, mode);
}
clearSelection(): void {
this.selection = new Set();
}
toggleSelected(id: string): void {
const next = new Set(this.selection);
if (next.has(id)) next.delete(id);
else next.add(id);
this.selection = next;
}
}
export const files = new FilesStore();
+69
View File
@@ -0,0 +1,69 @@
/**
* Session store — the authenticated user and derived flags.
*
* 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.
*/
import { fetchMe, tryRefresh } from '$lib/api/endpoints/auth';
import { listRootFolders } from '$lib/api/endpoints/folders';
import type { User } from '$lib/api/types';
class SessionStore {
user = $state<User | null>(null);
loaded = $state(false);
homeFolderId = $state<string | null>(null);
homeFolderName = $state<string | null>(null);
isExternalUser = $derived(this.user?.is_external ?? false);
isAuthenticated = $derived(this.user !== null);
/**
* Resolve the session once. Probes /api/auth/me; on 401 it makes a single
* refresh attempt and re-probes. Never redirects — the layout guard decides
* what to do with an unauthenticated result. Idempotent: subsequent calls
* return the cached result (so client-side navigation doesn't re-probe).
*/
async load(): Promise<User | null> {
if (this.loaded) return this.user;
try {
let me = await fetchMe();
if (!me && (await tryRefresh())) {
me = await fetchMe();
}
this.user = me;
} catch {
this.user = null;
}
this.loaded = true;
return this.user;
}
/**
* Resolve the home folder (first entry of GET /api/folders). Externals
* (grant-only) have no home folder, so this is skipped for them.
*/
async loadHomeFolder(): Promise<string | null> {
if (this.homeFolderId) return this.homeFolderId;
if (this.isExternalUser) return null;
try {
const folders = await listRootFolders();
if (folders.length > 0) {
this.homeFolderId = folders[0].id;
this.homeFolderName = folders[0].name;
}
} catch {
/* leave null — caller handles */
}
return this.homeFolderId;
}
reset(): void {
this.user = null;
this.homeFolderId = null;
this.homeFolderName = null;
}
}
export const session = new SessionStore();
+43
View File
@@ -0,0 +1,43 @@
/**
* Theme store — light / dark / auto.
*
* 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
* owns runtime changes from the UI.
*/
export type Theme = 'light' | 'dark' | 'auto';
const STORAGE_KEY = 'oxicloud_theme';
function readInitial(): Theme {
if (typeof localStorage === 'undefined') return 'auto';
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' || v === 'dark' ? v : 'auto';
}
const store = $state<{ theme: Theme }>({ theme: readInitial() });
function apply(theme: Theme): void {
if (typeof document === 'undefined') return;
const html = document.documentElement;
if (theme === 'light' || theme === 'dark') html.setAttribute('data-color-scheme', theme);
else html.removeAttribute('data-color-scheme');
}
export function setTheme(theme: Theme): void {
store.theme = theme;
if (typeof localStorage !== 'undefined') {
if (theme === 'auto') localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, theme);
}
apply(theme);
}
export const theme = {
get current() {
return store.theme;
},
set: setTheme
};
+176
View File
@@ -0,0 +1,176 @@
/**
* 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';
export interface Toast {
id: number;
message: string;
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;
/**
* 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);
}
return id;
}
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();
+10
View File
@@ -0,0 +1,10 @@
/* Global stylesheet: design tokens + base layer, imported once in +layout.svelte.
* Ported from static/css/base/*. Component-specific styles live in each
* component's scoped \3c style> block. */
@import url('./base/variables.css');
@import url('./base/reset.css');
@import url('./base/typography.css');
@import url('./base/forms.css');
@import url('./base/animations.css');
@import url('./base/a11y.css');
@import url('./ported.css');
+146
View File
@@ -0,0 +1,146 @@
/* ============================================================
* Accessibility baseline — keyboard focus.
*
* Pointer / programmatic focus stays ring-free (no "ring on every
* click" noise); KEYBOARD focus (:focus-visible) always gets a clear
* accent ring. Every interactive element inherits this automatically,
* so components only need their own :focus-visible rule when they want
* a custom ring — and must never strip it for keyboard users.
*
* The outline follows the element's border-radius in modern browsers,
* so rounded controls get a rounded ring for free.
* ============================================================ */
:focus:not(:focus-visible) {
outline: none;
}
:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* Skip link — visually hidden until focused, then slides in at top-left.
Lets keyboard users jump straight to <main id="main">. */
.skip-link {
position: absolute;
top: var(--space-2);
left: var(--space-2);
z-index: var(--z-max);
padding: var(--space-2) var(--space-4);
background: var(--color-bg-surface);
color: var(--color-text);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
transform: translateY(-150%);
transition: transform var(--motion-fast) var(--ease-standard);
}
.skip-link:focus {
transform: translateY(0);
}
/* ── prefers-reduced-motion ──────────────────────────────────
Vestibular safety: near-instant transitions/animations and no
smooth scroll for users who ask the OS to reduce motion. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* ── prefers-contrast: more ──────────────────────────────────
Collapse the muted text tiers up to the stronger secondary tier and
thicken the focus ring. The high-contrast border ramp (raw colour) lives
in base/variables.css — the token layer — so this file stays hex-free. */
@media (prefers-contrast: more) {
:root {
--color-text-muted: var(--color-text-secondary);
--color-text-subtle: var(--color-text-secondary);
--color-text-faint: var(--color-text-secondary);
}
:focus-visible {
outline-width: 3px;
}
}
/* ── forced-colors (Windows High Contrast) ───────────────────
Custom colors are overridden by the OS; ensure the keyboard
focus ring uses a real system colour. */
@media (forced-colors: active) {
:focus-visible {
outline-color: Highlight;
}
}
/* ── Global error-boundary toast (js/core/errorBoundary.js) ─── */
.error-toast {
position: fixed;
bottom: var(--space-5);
left: 50%;
z-index: var(--z-toast);
max-width: min(90vw, 420px);
padding: var(--space-3) var(--space-4);
background: var(--color-error-bg);
color: var(--color-error-text);
border: 1px solid var(--color-badge-error-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
font-size: var(--text-sm);
transform: translate(-50%, calc(100% + var(--space-5)));
transition: transform var(--motion-base) var(--ease-standard);
}
.error-toast.is-visible {
transform: translate(-50%, 0);
}
/* ── Print: drop the app chrome, show clean content ────────── */
@media print {
.sidebar,
.sidebar-overlay,
.top-bar,
.actions-bar,
.page-sticky-header,
.cmdk-overlay,
.skip-link,
.error-toast {
display: none !important;
}
.content-area,
.main-content {
overflow: visible !important;
}
* {
box-shadow: none !important;
}
}
/* ── Touch targets ───────────────────────────────────────────
≥44px hit areas for the key controls on touch/phone widths. */
@media (max-width: 768px) {
.sidebar-toggle,
.search-toggle-btn,
.search-back-btn,
.notif-bell-btn,
.user-avatar-btn {
min-width: 44px;
min-height: 44px;
}
.nav-item {
min-height: 44px;
}
.files-list-view .file-item {
min-height: 48px;
}
}
@@ -0,0 +1,18 @@
/* ============================================================
* Canonical keyframes — single source for shared animations.
*
* Loaded early via main.css so every component and view reuses
* these by name instead of redefining them. This file replaced
* 6 duplicate `@keyframes spin` definitions (spinner / admin /
* music / photos / profile / share-public).
*
* NOTE: `oxi-spin` (icons.css) and `smdSpin` (shareModal.css) are
* still defined locally — folding them in needs touching their
* `animation-name` consumers, deferred to Fase 1.
* ============================================================ */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+60
View File
@@ -0,0 +1,60 @@
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: var(--space-2);
font-weight: var(--weight-medium);
color: var(--color-text-secondary);
}
.form-group input,
.form-group textarea {
width: 100%;
padding: var(--space-2-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-base);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.button {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--text-base);
transition: background-color 0.2s;
}
.primary {
background-color: var(--color-accent);
color: var(--color-danger-text);
}
.primary:hover {
background-color: var(--color-accent-hover);
}
.secondary {
background-color: var(--color-border);
color: var(--color-text-secondary);
}
.secondary:hover {
background-color: var(--color-border-medium);
}
.danger {
background-color: var(--color-danger-bg);
color: var(--color-danger-text);
}
.danger:hover {
background-color: var(--color-danger-bg-hover);
}
+58
View File
@@ -0,0 +1,58 @@
/* Honor the user's browser font-size / zoom preference (rem-relative). */
html {
font-size: 100%;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: var(--font-sans);
}
body {
display: flex;
height: 100vh; /* fallback for browsers without dvh */
/* biome-ignore lint/suspicious/noDuplicateProperties: explicit fallback */
height: 100dvh;
font-size: var(--text-base);
line-height: var(--leading-normal);
background-color: var(--color-bg-page);
overflow: hidden;
}
html[dir="rtl"] .fa-arrow-left::before {
content: "\f061";
}
html[dir="rtl"] .fa-sign-out-alt {
-webkit-transform: rotate(180deg);
transform: rotate(180deg);
}
/* Utility: hide elements without inline style="" (CSP-safe) */
.hidden {
display: none !important;
}
/* Brand-tinted text selection + caret. */
::selection {
background: var(--color-accent-ring-strong);
color: var(--color-text-heading);
}
:root {
caret-color: var(--color-accent);
}
/* Visually hidden but exposed to assistive tech (a11y-only labels/headings). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@@ -0,0 +1,82 @@
/* ============================================================
* Typography utilities — Fase 0 foundations.
*
* Semantic heading roles DECOUPLED from element level, so any
* h1–h6 can carry the correct visual weight while the document
* keeps a correct, accessible heading order. All values route
* through the type/leading/weight/tracking tokens in variables.css.
*
* Views are migrated onto these classes in Fase 1 (replacing the
* per-page raw font-size/weight headings the audit flagged).
* ============================================================ */
/* Page title — one per page (the h1 role). */
.heading-page {
font-size: var(--text-2xl);
line-height: var(--leading-tight);
font-weight: var(--weight-bold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-heading);
}
/* Section heading. */
.heading-section {
font-size: var(--text-xl);
line-height: var(--leading-snug);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-tight);
color: var(--color-text-heading);
}
/* Card / panel heading. */
.heading-card {
font-size: var(--text-md);
line-height: var(--leading-snug);
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
}
/* Eyebrow / overline label (uppercase caps with tracking). */
.heading-eyebrow {
font-size: var(--text-xs);
line-height: var(--leading-normal);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-widest);
text-transform: uppercase;
color: var(--color-text-muted);
}
/* Constrain running text to a comfortable measure (~65ch). */
.prose {
max-width: var(--measure-prose);
}
/* Headings wrap with balanced line lengths (no single orphan word). */
h1,
h2,
h3,
.heading-page,
.heading-section,
.heading-card,
.page-title {
text-wrap: balance;
}
/* Running prose wraps "pretty" (avoids orphans and short last lines). */
.prose,
.empty-state p,
.about-description,
.auth-subtitle,
.auth-hint,
.language-subtitle {
text-wrap: pretty;
}
/* Tabular figures for numeric UI so digits align and don't jitter as they
change (storage readouts, badges, stat counters). */
.storage-info,
.user-menu-storage-text,
.notif-badge,
.stat-value {
font-variant-numeric: tabular-nums;
}
+694
View File
@@ -0,0 +1,694 @@
/*
* OxiCloud design tokens.
*
* Single source of truth for light + dark colours. Each token whose value
* differs between modes uses `light-dark(LIGHT, DARK)`, which the browser
* resolves against the page's `color-scheme`.
*
* Mode switching:
* • `<meta name="color-scheme" content="light dark">` in <head> declares
* both schemes are supported.
* • `:root { color-scheme: light dark }` (default) lets the UA follow the
* OS preference (`prefers-color-scheme`).
* • `html[data-color-scheme="light"]` / `…="dark"` force a specific mode.
* `theme-init.js` sets the attribute from localStorage.
*
* Fallback: browsers that don't support `light-dark()` (Chrome < 123 /
* Safari < 17.5 / Firefox < 120) hit a `@supports not (...)` block in
* `themes/dark.css` that still applies the old `[data-theme="dark"]` overrides.
*/
:root {
/* Default: follow the OS preference. Overridden by html[data-color-scheme]. */
color-scheme: light dark;
/* ════════════════════════════════════════════════════════════════
* NON-COLOR DESIGN SCALES (Fase 0 — fundamentos)
*
* Single source of truth for spacing, radius, typography, z-index,
* motion, elevation, breakpoints and density. Components are migrated
* onto these in Fase 1; until then raw px still coexist. Do NOT add
* raw px for spacing/radius/font-size in new code — consume a token.
* ════════════════════════════════════════════════════════════════ */
/* ── Layout shell ──────────────────────────────────────────── */
/* Fluid sidebar: tracks viewport but clamped to a sane band. */
--sidebar-width: clamp(220px, 18vw, 280px);
--sidebar-width-min: 200px; /* resizable rail floor (Fase 1) */
--sidebar-width-max: 320px; /* resizable rail ceiling (Fase 1) */
--sidebar-width-collapsed: 72px; /* icon-rail mode (Fase 1) */
--gutter: var(--space-6); /* shared topbar/content horizontal gutter (drops to 16px on phones) */
--grid-card-min: 200px; /* min width of a grid card (tightens on phones) */
/* ── Spacing — 4px grid ────────────────────────────────────── */
/* Direct steps are multiples of 4; half-steps (0-5/1-5/2-5/3-5)
* cover the high-frequency 2/6/10/14px raw values found in audit. */
--space-0: 0;
--space-px: 1px;
--space-0-5: 2px;
--space-1: 4px;
--space-1-5: 6px;
--space-2: 8px;
--space-2-5: 10px;
--space-3: 12px;
--space-3-5: 14px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-9: 36px;
--space-10: 40px;
--space-11: 44px;
--space-12: 48px;
--space-14: 56px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ── Radius ─────────────────────────────────────────────────── */
--radius-none: 0;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-3xl: 16px;
--radius-4xl: 20px;
--radius-full: 9999px;
/* Semantic default — resolves the legacy `var(--radius, 12px)` fallbacks
* in share-public.css / device-verify.css (token was never defined). */
--radius: var(--radius-2xl);
/* ── Typography ────────────────────────────────────────────── */
/* Font families (single source — reset.css `*` consumes --font-sans). */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
/* Modular size scale in rem (root = 16px) so it honors user zoom.
* --text-base (14px) is the app body default. */
--text-2xs: 0.6875rem; /* 11px */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.875rem; /* 14px */
--text-md: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.75rem; /* 28px */
--text-4xl: 2rem; /* 32px */
--text-5xl: 2.5rem; /* 40px */
--text-6xl: 3rem; /* 48px */
/* Line-heights (leading) — unitless ratios. */
--leading-none: 1;
--leading-tight: 1.25;
--leading-snug: 1.375;
--leading-normal: 1.5;
--leading-relaxed: 1.625;
--leading-loose: 1.8;
/* Font weights — numeric only (no bold/normal keywords). */
--weight-normal: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-extrabold: 800;
/* Letter-spacing (tracking) — em-relative. */
--tracking-tighter: -0.02em;
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.04em;
--tracking-widest: 0.08em;
/* Prose measure — comfortable line length for running text. */
--measure-prose: 65ch;
/* Icon glyph sizing — separate axis from the text scale. */
--icon-xs: 12px;
--icon-sm: 14px;
--icon-md: 16px;
--icon-lg: 20px;
--icon-xl: 24px;
/* ── Z-index — semantic stacking layers ────────────────────── */
/* Gaps left between layers so new surfaces slot in without renumber. */
--z-below: -1;
--z-base: 0;
--z-raised: 10;
--z-sticky: 100;
--z-dropdown: 1000;
--z-overlay: 2000;
--z-drawer: 2500;
--z-modal: 3000;
--z-popover: 3500;
--z-toast: 4000;
--z-tooltip: 5000;
--z-notification: 6000;
--z-max: 9999;
/* ── Motion — durations + easing curves ────────────────────── */
--motion-instant: 0ms;
--motion-fast: 120ms;
--motion-base: 160ms;
--motion-moderate: 200ms;
--motion-slow: 300ms;
--motion-slower: 500ms;
--motion-spinner: 1s;
--spin-duration: var(--motion-spinner);
/* Decelerate is the default for entrances / positive feedback. */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.3, 0, 0, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
/* ── Elevation — composed box-shadow recipes ───────────────── */
/* Full recipes (not bare alphas) layered on the --color-shadow-*
* alpha tokens below, so they adapt to light/dark automatically. */
--shadow-xs: 0 1px 2px var(--color-shadow-xs);
--shadow-sm: 0 1px 3px var(--color-shadow-sm), 0 1px 2px var(--color-shadow-xs);
--shadow-md: 0 4px 6px var(--color-shadow-sm), 0 2px 4px var(--color-shadow-xs);
--shadow-lg: 0 10px 15px var(--color-shadow-md), 0 4px 6px var(--color-shadow-sm);
--shadow-xl: 0 20px 25px var(--color-shadow-md), 0 8px 10px var(--color-shadow-sm);
--shadow-2xl: 0 25px 50px var(--color-shadow-lg);
/* ── Breakpoints (reference tokens) ────────────────────────── */
/* NOTE: @media cannot consume custom properties. These are the canonical
* values for JS (matchMedia) and documentation; consuming them in @media
* needs @custom-media via a postcss build step — pending dep approval. */
--bp-xs: 480px;
--bp-sm: 640px;
--bp-md: 768px;
--bp-lg: 1024px;
--bp-xl: 1280px;
/* ── Density — comfortable (default) vs compact ────────────── */
/* Gated by html[data-density="compact"] below. Consumed by list rows
* and controls in Fase 1. */
--density-row-py: var(--space-3); /* 12px */
--density-row-px: var(--space-3-5); /* 14px */
--density-gap: var(--space-3);
--density-control-h: 40px;
/* Backgrounds */
--color-bg-page: light-dark(#f5f7fa, #0f172a);
--color-bg-surface: light-dark(#ffffff, #1e293b);
--color-bg-input: light-dark(#f9fafb, #0f172a);
--color-bg-hover: light-dark(#f8fafc, #334155);
--color-bg-muted: light-dark(#f0f3f7, #1a2540);
--color-bg-subtle: light-dark(#f8f9fa, #162032);
--color-bg-alt: light-dark(#f7fafc, #0f172a);
--color-bg-input-alt: light-dark(#edf2f7, #253045);
--color-bg-empty: light-dark(#f0f0f0, #253045);
/* Borders */
--color-border: light-dark(#e2e8f0, #334155);
--color-border-light: light-dark(#f1f5f9, #334155);
--color-border-medium: light-dark(#cbd5e0, #475569);
--color-border-faint: light-dark(#e0e6ed, #2a3650);
--color-border-subtle: light-dark(#e0e5e8, #2a3650);
--color-border-xfaint: light-dark(#f0f0f0, #1e293b);
--color-border-ddd: light-dark(#ddd, #334155);
/* Text — collapsed to a few AA-passing tiers; every legacy name is kept as
* an alias so no consumer breaks (migration to the canonical names → Fase 1).
* Each tier clears WCAG AA 4.5:1 on #fff AND the page bg, light and dark. The
* muted/faint/placeholder tiers used to FAIL (2.3–4.0:1) and are now darkened. */
--color-text: light-dark(#2d3748, #e2e8f0); /* primary body */
--color-text-heading: light-dark(#1e293b, #f1f5f9); /* headings */
--color-text-secondary: light-dark(#475569, #cbd5e1); /* strong secondary */
/* muted/subtle/faint converge: AA 4.5:1 on the grayish page/muted bgs AND
* on the lighter dark hover bg leaves only a narrow passing window. */
--color-text-muted: light-dark(#5e6a78, #9fadbe); /* muted */
--color-text-subtle: light-dark(#5e6a78, #9fadbe); /* subtle */
--color-text-faint: light-dark(#5e6a78, #9fadbe); /* faintest still-AA */
/* legacy aliases → one of the tiers above */
--color-text-dark: var(--color-text-secondary);
--color-text-dim: var(--color-text-secondary);
--color-text-black: var(--color-text);
--color-text-gray: var(--color-text-muted);
--color-text-medium: var(--color-text-muted);
--color-text-faint2: var(--color-text-faint);
--color-text-light: var(--color-text-faint);
--color-text-placeholder: var(--color-text-faint);
/* Accent (orange) — mostly mode-agnostic. */
--color-accent: #ff5e3a;
--color-accent-hover: light-dark(#e04520, #ff7a5c);
/* AA-compliant accent for TEXT/LINKS: bare #ff5e3a only reaches 3.04:1 on
* white. Link/toggle-link consumers migrate onto this in Fase 2. */
--color-accent-text: light-dark(#cc3a16, #ff8a5c);
/* Solid foreground on accent fills (replaces reusing --color-danger-text). */
--color-on-accent: #ffffff;
/* Canonical keyboard focus-ring color (applied globally in Fase 2). */
--color-focus-ring: #ff5e3a;
/* Canonical logo gradient — unifies the divergent sidebar (#ff5e3a→#ff8a5c)
* vs accent (#ff5e3a→#ff2d55) logo fills. Consumers migrate in Fase 1. */
--color-logo-gradient: linear-gradient(135deg, #ff5e3a 0%, #ff8a5c 100%);
--color-accent-gradient: linear-gradient(135deg, #ff5e3a 0%, #ff2d55 100%);
--color-accent-shadow: rgba(255, 94, 58, 0.3);
--color-accent-ring: light-dark(rgba(255, 94, 58, 0.1), rgba(255, 94, 58, 0.15));
--color-accent-tint: light-dark(#fff5f3, #2a1a15);
--color-accent-mid: #ff8a5c;
--color-accent-shadow-lg: rgba(255, 94, 58, 0.4);
--color-accent-bg: rgba(255, 94, 58, 0.06);
--color-accent-bg-sm: rgba(255, 94, 58, 0.08);
--color-accent-ring-dark: rgba(255, 94, 58, 0.15);
--color-accent-ring-strong: rgba(255, 94, 58, 0.2);
--color-accent-ring-xl: rgba(255, 94, 58, 0.4);
--color-accent-ring-xs: rgba(255, 94, 58, 0.05);
--color-accent-glow: rgba(255, 94, 58, 0.2);
--color-accent-glow-soft: rgba(255, 94, 58, 0.1);
/* Ambient brand backdrop — shared by the external surfaces (login / share /
device). A centred "spotlight" (surface is brighter than page in BOTH
light and dark) seats the card in a pool of light; four warm brand blobs
fill the field so it reads as a deliberate, dimensional canvas rather than
flat near-white. Resolves through light-dark() automatically. */
--brand-ambient:
radial-gradient(55% 50% at 8% 4%, var(--color-accent-glow), transparent 60%),
radial-gradient(55% 55% at 95% 98%, var(--color-accent-glow), transparent 58%),
radial-gradient(48% 48% at 88% 12%, var(--color-accent-glow-soft), transparent 55%),
radial-gradient(50% 45% at 6% 92%, var(--color-accent-glow-soft), transparent 55%),
radial-gradient(78% 64% at 50% 33%, var(--color-bg-surface), transparent 70%), var(--color-bg-page);
/* Desaturated fractal-noise grain — kills gradient banding and adds a
tactile, "expensive" film. No colour inside the data-URI (token-safe);
applied via a low-opacity overlay pseudo-element. */
--brand-grain: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='180' height='180' filter='url(%23g)'/></svg>");
/* Feedback — success unified to one restrained emerald; the 6 green variants
* now alias the canonical bg/text. (error-* is the danger tint, kept.) */
--color-error-bg: light-dark(#fee2e2, #3b1111);
--color-error-text: light-dark(#b91c1c, #fca5a5);
--color-success-bg: light-dark(#dcfce7, #052e16);
--color-success-text: light-dark(#15803d, #86efac);
--color-success-border: #16a34a;
--color-success-alt: #16a34a;
--color-success-bg-alt: var(--color-success-bg);
--color-success-text-alt: var(--color-success-text);
--color-success-bg-green: var(--color-success-bg);
--color-success-text-green: var(--color-success-text);
/* Dangerous actions — unified on #ef4444 / #dc2626; danger text-alt now uses
* the AA-passing error-text instead of a sub-4.5:1 red. */
--color-danger-bg: #ef4444;
--color-danger-text: #ffffff;
--color-danger-bg-hover: #dc2626;
--color-danger-alt: #ef4444;
--color-danger-ring: rgba(239, 68, 68, 0.3);
--color-danger-ring-lg: rgba(239, 68, 68, 0.4);
--color-danger-light-bg: light-dark(#fef2f2, #2a0c0c);
--color-danger-lighter: light-dark(#fef2f2, #2a0c0c);
--color-danger-text-alt: var(--color-error-text);
--color-danger-gradient: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
/* Warning — unified to one restrained amber. Text tier (#b45309) clears AA;
* the bright #ffc107 gold is replaced by amber-500 for borders/fills. Legacy
* orange/amber variants alias the canonical tokens. */
--color-warning-bg: light-dark(#fef3c7, #2a2410);
--color-warning-text: light-dark(#b45309, #fbbf24);
--color-warning-border: #f59e0b;
--color-warning-bg-dark: light-dark(#fde68a, #3d2e00);
--color-warning-ring: rgba(245, 158, 11, 0.12);
--color-warning-shadow: rgba(245, 158, 11, 0.4);
--color-warning-orange-bg: var(--color-warning-bg);
--color-warning-orange-border: var(--color-warning-border);
--color-warning-orange-text: var(--color-warning-text);
--color-warning-bg-light: var(--color-warning-bg);
--color-warning-text-amber: var(--color-warning-text);
--color-warning-bg-orange: var(--color-warning-bg);
--color-warning-text-orange: var(--color-warning-text);
/* Info — unified to one blue (AA text #1d4ed8, with a light-dark dark tier).
* Variants alias the canonical tokens. */
--color-info-bg: light-dark(#eff6ff, #0c2d48);
--color-info-text: light-dark(#1d4ed8, #93c5fd);
--color-info-border: #3b82f6;
--color-info-blue: #3b82f6;
--color-info-bg-alt: var(--color-info-bg);
--color-info-text-alt: var(--color-info-text);
--color-info-surface: var(--color-info-bg);
/* Shadows — stronger in dark mode for parity. Dark values form a
* deliberate progression (base 0.3 < md 0.34 < lg 0.38) so elevation
* levels stay perceptually distinct; they used to all collapse to 0.3. */
--color-shadow: light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.3));
--color-shadow-lg: light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.38));
--color-shadow-xs: rgba(0, 0, 0, 0.05);
--color-shadow-sm: rgba(0, 0, 0, 0.08);
--color-shadow-md: light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.34));
--color-shadow-xl: rgba(0, 0, 0, 0.2);
--color-shadow-2xl: rgba(0, 0, 0, 0.25);
--color-shadow-3xl: rgba(0, 0, 0, 0.3);
--color-shadow-4xl: rgba(0, 0, 0, 0.4);
/* Overlays — same in both modes; they sit on top of arbitrary content. */
--color-overlay: rgba(0, 0, 0, 0.5);
/* Frosted scrim behind overlay controls (favorite/kebab/checkbox) so they
stay legible on top of any thumbnail, light or dark. */
--color-scrim-control: light-dark(rgba(255, 255, 255, 0.92), rgba(15, 23, 42, 0.82));
--color-overlay-light: rgba(0, 0, 0, 0.45);
--color-overlay-heavy: rgba(0, 0, 0, 0.85);
--color-overlay-darkest: rgba(0, 0, 0, 0.92);
--color-overlay-shadow: rgba(0, 0, 0, 0.6);
/* Foreground / control surfaces on top of dark overlays */
--color-on-overlay: rgba(255, 255, 255, 0.95);
--color-on-overlay-muted: rgba(255, 255, 255, 0.9);
--color-overlay-button: rgba(255, 255, 255, 0.12);
--color-overlay-button-hover: rgba(255, 255, 255, 0.22);
/* Items */
--color-item: var(--color-bg-surface);
--color-item-hover: var(--color-bg-hover);
--color-item-active: light-dark(#f8d2ae, #5a5047);
--color-item-selected: light-dark(#fff8f6, #39281a);
--color-item-hover-accent: light-dark(#fff0ec, #3d342c);
--color-item-hover-blue: #f0f8ff;
--color-item-hover-sky: #e0f2fe;
/* Sidebar — already dark-leaning in both modes; dark mode goes deeper. */
--color-sidebar-bg-from: light-dark(#2a3042, #0f172a);
--color-sidebar-bg-to: light-dark(#232838, #0c1322);
--color-sidebar-text: rgba(255, 255, 255, 0.65);
--color-sidebar-text-hover: rgba(255, 255, 255, 0.9);
--color-sidebar-text-active: #ffffff;
--color-sidebar-active-bg: rgba(255, 94, 58, 0.12);
--color-sidebar-hover-bg: rgba(255, 255, 255, 0.06);
--color-sidebar-separator: rgba(255, 255, 255, 0.07);
--color-sidebar-overlay: rgba(0, 0, 0, 0.5);
--color-sidebar-storage-bg: rgba(255, 255, 255, 0.05);
--color-sidebar-storage-border: rgba(255, 255, 255, 0.07);
--color-sidebar-storage-text: rgba(255, 255, 255, 0.8);
--color-sidebar-storage-bar: rgba(255, 255, 255, 0.1);
--color-sidebar-storage-faint: rgba(255, 255, 255, 0.5);
--color-sidebar-logo-gradient: linear-gradient(135deg, #ff5e3a 0%, #ff8a5c 100%);
--color-sidebar-progress: linear-gradient(90deg, #ff5e3a 0%, #ff8a5c 100%);
--color-sidebar-shadow: rgba(255, 94, 58, 0.35);
--color-sidebar-shadow-lg: rgba(255, 94, 58, 0.45);
/* Calendar dots — regenerated at fixed S=55% L=62%, hues evenly around the
* wheel, so they read as one curated family (not confetti). These also tint
* the sidebar nav icons (sidebar.css :nth-child rules). */
--color-cal-1: #d36868;
--color-cal-2: #d3a268;
--color-cal-3: #c9d368;
--color-cal-4: #8fd368;
--color-cal-5: #68d37c;
--color-cal-6: #68d3b6;
--color-cal-7: #68b6d3;
--color-cal-8: #687cd3;
--color-cal-9: #8f68d3;
--color-cal-10: #c968d3;
--color-cal-11: #d368a2;
/* File type badge colors */
--color-ft-html: #e34c26;
--color-ft-js: #2965f1;
--color-ft-python: #3776ab;
--color-ft-typescript: #3178c6;
--color-ft-rust: #dea584;
--color-ft-go: #00add8;
--color-ft-java: #e76f00;
--color-ft-shell: #555555;
--color-ft-csharp: #68217a;
--color-ft-php: #8892be;
--color-ft-ruby: #cc342d;
--color-ft-swift: #fa7343;
--color-ft-kotlin: #7f52ff;
--color-ft-scala: #e38c00;
--color-ft-angular: #cb171e;
--color-ft-cpp: #9c4221;
--color-ft-docker: #083fa1;
--color-ft-generic-blue: #556ee6;
--color-ft-generic-green: #4eaa25;
--color-ft-generic-gray: #a0aec0;
--color-ft-orange-light: #ffb86c;
--color-ft-yellow: #ffd43b;
--color-ft-orange-alt: #e34c26;
--color-ft-coffeescript: #9c4221;
/* File type icon background/text pairs */
--color-ft-folder-bg: #ffeaa7;
--color-ft-folder-tab: #fdcb6e;
--color-ft-doc-bg: #e0ecff;
--color-ft-doc-text: #3171d8;
--color-ft-pdf-bg: #fee2e2;
--color-ft-pdf-text: #e53e3e;
--color-ft-image-bg: #e0f2fe;
--color-ft-image-text: #3b82f6;
--color-ft-video-bg-from: #ede9fe;
--color-ft-video-bg-to: #fce7f3;
--color-ft-video-text: #8b5cf6;
--color-ft-audio-bg: #fef3c7;
--color-ft-audio-text: #f59e0b;
--color-ft-audio-alt-bg: #fff3e0;
--color-ft-spreadsheet-bg: #e6f4ea;
--color-ft-spreadsheet-text: #0d904f;
--color-ft-presentation-bg: #fef3e2;
--color-ft-presentation-text: #d04423;
--color-ft-archive-bg: #f5f0eb;
--color-ft-archive-text: #8d6e63;
--color-ft-installer-bg: #f3e8ff;
--color-ft-installer-text: #7c3aed;
--color-ft-script-bg: #e8f5e9;
--color-ft-script-text: #4eaa25;
--color-ft-config-bg: #f1f3f5;
--color-ft-config-text: #718096;
/* Multiselect bar — always dark */
--color-multiselect-bg: #1e293b;
--color-multiselect-border: #334155;
--color-multiselect-text: #ffffff;
--color-multiselect-text-faint: rgba(255, 255, 255, 0.7);
--color-multiselect-hover-bg: rgba(255, 255, 255, 0.1);
--color-multiselect-action-text: #ffffff;
--color-multiselect-action-hover: rgba(255, 255, 255, 0.2);
--color-multiselect-danger-bg: rgba(239, 68, 68, 0.25);
--color-multiselect-danger-text: #fca5a5;
--color-multiselect-danger-active: rgba(239, 68, 68, 0.4);
--color-multiselect-danger-text-active: #ffffff;
/* Notification */
--color-notification-bg: light-dark(#ffffff, #1e293b);
--color-notification-badge: #ff3b30;
--color-notification-success: #34c759;
--color-notification-error: #ff3b30;
/* Photos lightbox — always dark overlay */
--color-lightbox-overlay: rgba(0, 0, 0, 0.92);
--color-lightbox-btn-bg: rgba(255, 255, 255, 0.12);
--color-lightbox-btn-text: #ffffff;
--color-lightbox-btn-hover: rgba(255, 255, 255, 0.25);
--color-lightbox-gradient-top: linear-gradient(to bottom, rgba(0, 0, 0, 0.6), transparent);
--color-lightbox-gradient-bottom: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent);
--color-lightbox-text-faint: rgba(255, 255, 255, 0.5);
--color-lightbox-text-muted: rgba(255, 255, 255, 0.7);
/* (Removed: the --color-purple-* family had zero consumers. The music
* gradient and the video file-type purple are separate, retained tokens.) */
/* OIDC / auth */
--color-oidc-bg: var(--color-info-blue);
--color-oidc-shadow: rgba(79, 70, 229, 0.3);
--color-oidc-shadow-lg: rgba(79, 70, 229, 0.4);
/* Device verify */
--color-device-verify-text: #ffc107;
--color-device-verify-shadow: rgba(255, 193, 7, 0.5);
--color-device-verify-drop-shadow: rgba(255, 193, 7, 0.4);
--color-device-verify-border: #ffc107;
--color-device-verify-muted: #6c757d;
--color-device-verify-dim: #ccc;
/* Content area */
--color-content-muted: #888;
--color-content-bg-warn: light-dark(#ffeaa7, #3d2e00);
--color-content-bg-warn-dark: light-dark(#fdcb6e, #5a4200);
/* User menu */
--color-user-menu-header-bg: light-dark(linear-gradient(135deg, #fef5f3 0%, #fdf2f8 100%), linear-gradient(135deg, #1a2332 0%, #1e2940 100%));
--color-user-menu-header-border: light-dark(#fce7e1, #3a2520);
/* Share dialog */
--color-share-link-text: var(--color-info-text);
--color-share-link-hover: var(--color-info-text);
--color-share-remove-text: #b71c1c;
--color-share-owner-text: #757575;
/* Primary (style.css) */
/* Demoted: orange is the sole brand/primary — primary now aliases the accent
* (was a competing blue #2563eb). Flips device-verify / share / userMenu to brand. */
--color-primary: var(--color-accent);
--color-primary-hover: var(--color-accent-hover);
/* Recent view */
--color-recent-muted: #6c757d;
--color-recent-border: #6c757d;
/* Star colors */
--color-star-text: #fbbf24;
--color-star-text-hover: #f59e0b;
--color-star-active: #d97706;
/* Card drop target */
--color-card-drop-tint: rgba(230, 126, 34, 0.08);
--color-card-drop-border: #e67e22;
/* Neutral backgrounds */
--color-neutral-warm-bg: #f5f0eb;
--color-neutral-warm-text: #8d6e63;
--color-neutral-bg: #f1f3f5;
/* Admin/profile blue accent */
--color-admin-blue: #60a5fa;
--color-admin-blue-bg: rgba(59, 130, 246, 0.1);
--color-admin-blue-bg-sm: rgba(59, 130, 246, 0.15);
/* Danger hover bg (for logout etc.) */
--color-danger-hover-bg: rgba(239, 68, 68, 0.1);
/* Additional success rings */
--color-success-ring: rgba(72, 187, 120, 0.1);
--color-success-ring-dark: rgba(72, 187, 120, 0.15);
--color-success-text-strong: #2f855a;
--color-success-ring-vivid: rgba(74, 222, 128, 0.1);
--color-success-text-vivid: #86efac;
--color-success-ring-vivid-lg: rgba(74, 222, 128, 0.15);
--color-success-icon-vivid: #4ade80;
--color-secret-green: #059669;
/* Additional overlays */
--color-overlay-mid: rgba(0, 0, 0, 0.6);
--color-overlay-video: rgba(0, 0, 0, 0.55);
/* Progress overlays */
--color-progress-overlay: rgba(255, 255, 255, 0.95);
--color-progress-overlay-dark: rgba(30, 41, 59, 0.95);
/* Misc */
--color-black: #000000;
--color-info-border-light: #90cdf4;
--color-notification-error-ring: rgba(255, 59, 48, 0.1);
--color-accent-second: #ff2d55;
--color-warning-ring-xs: rgba(255, 193, 7, 0.05);
/* Avatar / profile */
--color-avatar-gradient: linear-gradient(135deg, #3b82f6, #6366f1);
--color-text-navy: #1a1a2e;
--color-role-admin-bg: #dbeafe;
--color-role-admin-text: #1d4ed8;
--color-dark-mid: #475569;
--color-role-admin-dark-bg: #1e3a5f;
/* Photo tile */
--color-photo-check-border: rgba(255, 255, 255, 0.8);
/* Accent shadow (smaller) */
--color-accent-shadow-sm: rgba(255, 94, 58, 0.25);
/* Warning faint background */
--color-warning-bg-faint: #fffbeb;
/* Storage progress fill gradients */
--color-storage-fill-green: linear-gradient(90deg, #059669, #10b981);
--color-storage-fill-orange: linear-gradient(90deg, #d97706, #f59e0b);
--color-storage-fill-red: linear-gradient(90deg, #dc2626, #ef4444);
/* Error text (dark shade) — light-mode is dark red, dark-mode is light red. */
--color-error-text-dark: light-dark(#991b1b, #f87171);
/* Stat warning border */
--color-stat-warn-border: #fbbf24;
/* Status badge — success/emerald */
--color-badge-success-bg: #ecfdf5;
--color-badge-success-bg-medium: #d1fae5;
--color-badge-success-text: #065f46;
--color-badge-success-border: #a7f3d0;
--color-badge-success-fill: #047857;
--color-badge-success-fill-dark: #064e27;
--color-badge-success-fill-faint: #f0fdf4;
--color-badge-green-bg: #ecfdf5;
--color-badge-green-text: #065f46;
/* Status badge — orange/coral (used by role-chip & user-vignette) */
--color-badge-orange-bg: light-dark(#fff5f3, #2a1814);
--color-badge-orange-text: light-dark(#ff5e3a, #ff8a65);
/* Status badge — error/red */
--color-badge-error-border: #fecaca;
/* Status badge — warning/amber */
--color-badge-warning-text: #92400e;
--color-badge-warning-border: #fde68a;
--color-badge-amber-bg: #fef3c7;
--color-badge-amber-text: #f59e0b;
/* Status badge — indigo/purple */
--color-badge-indigo-bg: #ede9fe;
--color-badge-indigo-text: #6d28d9;
/* Status badge — blue (used by role-chip & user-vignette) */
--color-badge-blue-bg: light-dark(#eff6ff, #0c2d48);
--color-badge-blue-text: light-dark(#1e40af, #93c5fd);
--color-badge-blue-border: #bfdbfe;
/* Status badge — gray/disabled */
--color-badge-gray: #d1d5db;
/* (Removed: the legacy dark-mode badge tokens that lived here had zero
* consumers — the light-dark() badge tokens above are the single source.) */
/* Dark structural */
--color-dark-footer: #162032;
--color-scrollbar-dark: rgba(255, 255, 255, 0.15);
--color-border-dark-faint: rgba(255, 255, 255, 0.03);
/* Misc */
--color-bg-off-white: #fafbfd;
--color-danger-shadow: rgba(220, 38, 38, 0.2);
--color-danger-shadow-lg: rgba(220, 38, 38, 0.3);
--color-music-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--color-music-background: var(--color-bg-surface);
--color-music-public-bg: rgba(74, 144, 217, 0.12);
--color-video-play: #ffffff;
--color-video-play-shadow: #000000;
}
/* Explicit user choice overrides the OS preference. `theme-init.js` writes
* the attribute from localStorage on render-blocking startup. */
html[data-color-scheme="light"] {
color-scheme: light;
}
html[data-color-scheme="dark"] {
color-scheme: dark;
}
/* Compact density — tighter rows/controls. Toggled by writing
* data-density="compact" on <html>; consumed by list/control CSS in Fase 1. */
html[data-density="compact"] {
--density-row-py: var(--space-2); /* 8px */
--density-row-px: var(--space-2-5); /* 10px */
--density-gap: var(--space-2);
--density-control-h: 32px;
}
/* High-contrast border ramp (prefers-contrast: more). Lives here in the token
* layer — the only place raw colour values belong — so the "no raw hex outside
* variables/themes" invariant holds. The matching text-tier collapse + focus
* ring (token/outline only) stay in base/a11y.css. */
@media (prefers-contrast: more) {
:root {
--color-border: light-dark(#64748b, #94a3b8);
--color-border-light: light-dark(#64748b, #94a3b8);
--color-border-medium: light-dark(#475569, #cbd5e1);
}
}
+18
View File
@@ -0,0 +1,18 @@
/* Layout/component CSS ported verbatim from the OxiCloud frontend's static/css.
* These are token-based and global; the Svelte components emit the same class
* names and DOM so the rewrite matches the established design. Kept byte-faithful
* (linters ignore this dir) — restyle via tokens in variables.css, not here. */
@import url('./ported/sidebar.css');
@import url('./ported/topbar.css');
@import url('./ported/content.css');
@import url('./ported/buttons.css');
@import url('./ported/uploadDropdown.css');
@import url('./ported/breadcrumb.css');
@import url('./ported/fileManager.css');
@import url('./ported/resourceList.css');
@import url('./ported/batchToolbar.css');
@import url('./ported/skeleton.css');
@import url('./ported/notifications.css');
@import url('./ported/userMenu.css');
@import url('./ported/auth.css');
@import url('./ported/music.css');
+805
View File
@@ -0,0 +1,805 @@
/* ============================================================
Auth styles for OxiCloud — design tokens from variables.css
============================================================ */
.auth-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100dvh;
width: 100%;
position: relative;
/* Spotlight + warm brand blobs — seats the card in a pool of light
instead of a flat near-white field. */
background: var(--brand-ambient);
}
/* Fine film grain over the backdrop (not the card). `overlay` neutralises the
mid-grey noise so it adds texture without shifting brightness; works in both
light and dark. Tune the whole effect with `opacity`. */
.auth-container::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image: var(--brand-grain);
background-size: 180px 180px;
opacity: 0.6;
mix-blend-mode: overlay;
}
/* Keep the card (and every panel) above the grain layer. */
.auth-panel {
position: relative;
z-index: 1;
}
.auth-panel {
width: 420px;
max-width: 90%;
margin: 0 auto;
background-color: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-3xl);
box-shadow: var(--shadow-xl);
padding: var(--space-9);
text-align: center;
}
.auth-logo {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-5);
}
.auth-logo-icon {
width: 52px;
height: 52px;
background: var(--color-accent-gradient);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--space-3);
/* Tighter glow (negative spread) reads more premium than a wide halo. */
box-shadow: 0 4px 14px -4px var(--color-accent-shadow);
}
.auth-logo-icon svg {
width: 30px;
height: 30px;
fill: var(--color-on-accent);
}
.auth-logo-text {
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
color: var(--color-text);
}
.auth-title {
font-size: 22px;
font-weight: var(--weight-bold);
margin-bottom: var(--space-7);
color: var(--color-text-heading);
}
.auth-form {
width: 100%;
text-align: left;
[dir="rtl"] & {
text-align: right;
}
}
.auth-input-group {
margin-bottom: var(--space-5);
}
.auth-label {
display: block;
margin-bottom: var(--space-2);
font-size: var(--text-base);
color: var(--color-text-heading);
font-weight: var(--weight-semibold);
}
.auth-input {
width: 100%;
padding: var(--space-3-5) 18px;
border-radius: var(--radius-2xl);
/* Stronger resting border so fields read as crafted, not flat fills. */
border: 2px solid var(--color-border-medium);
font-size: 15px;
background-color: var(--color-bg-input);
color: var(--color-text);
transition: all 0.2s ease;
}
.auth-input::placeholder {
color: var(--color-text-muted);
}
.auth-input:hover {
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
}
.auth-input:focus {
outline: none;
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
.auth-input[readonly] {
cursor: default;
/* A locked value, not a placeholder: full-strength text on a subtly
distinct "locked" fill (no dimming that reads as empty). */
color: var(--color-text);
font-weight: var(--weight-semibold);
background-color: var(--color-bg-input-alt);
}
.auth-button {
width: 100%;
padding: var(--space-3-5) 18px;
border-radius: var(--radius-2xl);
background: var(--color-accent-gradient);
color: var(--color-on-accent);
font-weight: var(--weight-bold);
border: none;
cursor: pointer;
font-size: var(--text-md);
transition: all 0.3s ease;
margin-top: var(--space-3);
box-shadow: 0 4px 12px var(--color-accent-shadow);
}
.auth-button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px var(--color-accent-shadow-lg);
filter: brightness(1.05);
}
.auth-button:active {
/* Tactile press — the button dips slightly under the resting plane. */
transform: translateY(1px);
box-shadow: 0 2px 8px var(--color-accent-shadow);
}
.auth-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
filter: none;
}
/* Loading — hide the label, show an inline spinner (toggled via .is-loading /
aria-busy by auth.js on submit). */
.auth-button.is-loading,
.auth-button[aria-busy="true"] {
color: transparent;
pointer-events: none;
position: relative;
}
.auth-button.is-loading::after,
.auth-button[aria-busy="true"]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border: 2px solid var(--color-on-accent);
border-top-color: transparent;
border-radius: var(--radius-full);
animation: spin var(--spin-duration) linear infinite;
}
.auth-subtitle {
margin: var(--space-5) 0;
color: var(--color-text-muted);
font-size: var(--text-base);
}
.auth-action-wrap {
margin-top: var(--space-5);
}
/* SSO / OIDC button */
.auth-button-oidc {
background: linear-gradient(135deg, var(--color-text) 0%, var(--color-text-secondary) 100%);
box-shadow: 0 4px 12px var(--color-shadow-3xl);
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2-5);
}
.auth-button-oidc:hover {
box-shadow: 0 6px 20px var(--color-shadow-4xl);
}
.auth-button-sso {
background: var(--color-oidc-bg);
box-shadow: 0 4px 12px var(--color-oidc-shadow);
}
.auth-button-sso:hover {
box-shadow: 0 6px 20px var(--color-oidc-shadow-lg);
}
.auth-button-oidc i {
font-size: var(--text-base);
}
/* Helper text above the magic-link form ("No password? Enter your
email…"). Quieter visual weight than the form labels. */
.auth-hint {
margin: 0 0 var(--space-3);
font-size: var(--text-sm);
line-height: 1.4;
color: var(--color-text-secondary);
}
/* Status banner under the magic-link form. Uniform anti-enumeration
message rendered on every successful 2xx; error variant only used
for the 503-not-configured branch or network failures. */
.auth-status {
margin-top: var(--space-3);
padding: var(--space-2-5) var(--space-3-5);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
line-height: 1.4;
}
.auth-status-success {
background: var(--color-bg-hover);
color: var(--color-text);
border-left: 3px solid var(--color-warning-orange-text);
}
.auth-status-error {
background: var(--color-bg-hover);
color: var(--color-text);
border-left: 3px solid var(--color-warning-orange-text);
}
/* Divider between password and SSO login */
.auth-divider {
display: flex;
align-items: center;
margin: var(--space-5) 0;
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.auth-divider::before,
.auth-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
.auth-divider span {
padding: 0 var(--space-3);
/* Quiet, deliberate label rather than a stray lowercase letter. */
text-transform: uppercase;
letter-spacing: var(--tracking-wide, 0.08em);
font-size: var(--text-2xs);
font-weight: var(--weight-semibold);
color: var(--color-text-muted);
}
/* OIDC-only mode: hide password form */
.auth-form.hidden {
display: none;
}
.auth-toggle {
margin-top: 22px;
font-size: var(--text-base);
color: var(--color-text-muted);
}
.auth-toggle-link {
color: var(--color-accent-text);
cursor: pointer;
text-decoration: none;
font-weight: var(--weight-medium);
}
.auth-toggle-link:hover {
text-decoration: underline;
}
.auth-error {
background-color: var(--color-error-bg);
color: var(--color-error-text);
padding: var(--space-3) 18px;
border-radius: var(--radius-2xl);
margin-bottom: var(--space-5);
font-size: var(--text-base);
display: none;
}
.auth-success {
background-color: var(--color-success-bg);
color: var(--color-success-text);
padding: var(--space-3) 18px;
border-radius: var(--radius-2xl);
margin-bottom: var(--space-5);
font-size: var(--text-base);
display: none;
}
/* Admin setup panel styles — visibility controlled via .hidden class */
.setup-steps {
margin-bottom: var(--space-7);
/* 3 equal columns → circle centres land at 1/6, 1/2, 5/6, so the
connector track can be placed deterministically between them. */
display: grid;
grid-template-columns: repeat(3, 1fr);
position: relative;
}
/* Connector track behind the step circles (z-index 0; circles sit above). */
.setup-steps::before {
content: "";
position: absolute;
top: 17px; /* half of the 34px circle */
left: 16.667%;
right: 16.667%;
height: 2px;
background: var(--color-border);
z-index: 0;
}
.setup-step {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.step-number {
width: 34px;
height: 34px;
background-color: var(--color-bg-input);
border: 2px solid var(--color-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-faint);
font-weight: var(--weight-bold);
font-size: var(--text-base);
margin-bottom: var(--space-1-5);
transition: all 0.2s ease;
}
.step-number.active {
background: var(--color-accent-gradient);
border-color: transparent;
color: var(--color-on-accent);
/* Subtle halo lifts the active step off the connector track. */
box-shadow:
0 0 0 4px var(--color-accent-ring),
0 4px 12px var(--color-accent-shadow);
}
.step-title {
font-size: var(--text-xs);
color: var(--color-text-faint);
font-weight: var(--weight-medium);
}
.step-title.active {
color: var(--color-text-heading);
font-weight: var(--weight-semibold);
}
/* Language selector panel styles */
.language-selector-panel {
text-align: center;
}
.language-subtitle {
color: var(--color-text-muted);
font-size: var(--text-md);
margin-bottom: var(--space-6);
}
/* ====== Compact Language Picker ====== */
.lang-picker {
position: relative;
margin-bottom: var(--space-6);
text-align: left;
}
.lang-picker-selected {
display: flex;
align-items: center;
padding: var(--space-3-5) 18px;
border: 2px solid var(--color-border);
border-radius: var(--radius-2xl);
cursor: pointer;
background-color: var(--color-bg-input);
transition: all 0.2s ease;
user-select: none;
}
.lang-picker-selected:hover {
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
}
.lang-picker.open .lang-picker-selected {
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
.lang-picker-flag {
font-size: 26px;
margin-right: var(--space-3-5);
flex-shrink: 0;
}
.lang-picker-name {
font-size: var(--text-md);
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
flex: 1;
}
.lang-picker-arrow {
color: var(--color-text-faint);
font-size: var(--text-sm);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.lang-picker.open .lang-picker-arrow {
transform: rotate(180deg);
}
/* Dropdown */
.lang-picker-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--color-bg-surface);
border: 2px solid var(--color-accent);
border-top: 1px solid var(--color-border-light);
border-bottom-left-radius: var(--radius-2xl);
border-bottom-right-radius: var(--radius-2xl);
box-shadow: 0 12px 32px var(--color-shadow-lg);
z-index: 100;
overflow: hidden;
}
.lang-picker.open .lang-picker-dropdown {
display: block;
animation: langPickerSlideDown 0.2s ease;
}
@keyframes langPickerSlideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Search inside dropdown */
.lang-picker-search {
position: relative;
padding: var(--space-2-5) var(--space-3-5);
border-bottom: 1px solid var(--color-border-light);
}
.lang-picker-search i {
position: absolute;
left: 26px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-faint);
font-size: var(--text-sm);
}
.lang-picker-search input {
width: 100%;
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
font-size: var(--text-base);
outline: none;
box-sizing: border-box;
background-color: var(--color-bg-input);
color: var(--color-text);
transition: border-color 0.2s;
}
.lang-picker-search input::placeholder {
color: var(--color-text-faint);
}
.lang-picker-search input:focus {
border-color: var(--color-accent);
}
/* Scrollable list */
.lang-picker-list {
max-height: 240px;
overflow-y: auto;
padding: var(--space-1-5);
}
/* Language item in dropdown */
.lang-picker-item {
display: flex;
align-items: center;
gap: var(--space-2-5);
padding: var(--space-2-5) var(--space-3);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.12s ease;
}
.lang-picker-item:hover {
background: var(--color-bg-hover);
}
.lang-picker-item.selected {
background: var(--color-accent-tint);
}
.lang-picker-item-flag {
font-size: 22px;
flex-shrink: 0;
}
.lang-picker-item-name {
font-size: 15px;
font-weight: var(--weight-medium);
color: var(--color-text-heading);
}
.lang-picker-item-english {
font-size: var(--text-sm);
color: var(--color-text-faint);
margin-left: auto;
}
.lang-picker-item-check {
color: var(--color-accent);
font-size: var(--text-sm);
flex-shrink: 0;
}
.lang-picker-empty {
text-align: center;
color: var(--color-text-faint);
padding: var(--space-5);
font-size: var(--text-base);
}
@media (max-width: 480px) {
.auth-panel {
width: 95%;
padding: var(--space-6);
}
.lang-picker-selected {
padding: var(--space-3) var(--space-3-5);
}
.lang-picker-list {
max-height: 200px;
}
}
/* ============================================================
Premium polish — brand lockup, CTA hierarchy, field icons,
password reveal, progressive disclosure, match feedback.
Icons are token-safe CSS masks (no inline SVG, no raw colour):
the glyph alpha comes from the data-URI, the colour from a token.
============================================================ */
/* — Brand wordmark: the ownable "Oxi" accent lockup (DESIGN-SYSTEM §3) — */
.brand-oxi {
background: var(--color-accent-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* — CTA hierarchy: exactly one primary per screen. The secondary action
(magic-link, etc.) is a quiet tinted/ghost button, never a 2nd gradient. — */
.auth-button-secondary {
background: var(--color-accent-tint);
color: var(--color-accent-text);
border: 1.5px solid var(--color-border-medium);
box-shadow: none;
}
.auth-button-secondary:hover {
background: var(--color-bg-surface);
border-color: var(--color-accent);
box-shadow: none;
filter: none;
transform: translateY(-1px);
}
.auth-button-secondary:active {
transform: translateY(1px);
box-shadow: none;
}
/* — Leading field icons (user / mail / lock) via masked pseudo-element — */
.auth-input-wrap {
position: relative;
}
.auth-input-wrap .auth-input {
padding-left: 44px;
}
.auth-input-wrap.has-toggle .auth-input {
padding-right: 44px;
}
.auth-input-wrap::before {
content: "";
position: absolute;
left: 16px;
top: 50%;
width: 18px;
height: 18px;
transform: translateY(-50%);
background-color: var(--color-text-muted);
pointer-events: none;
z-index: 1;
transition: background-color 0.2s ease;
-webkit-mask: var(--icon-url, none) center / contain no-repeat;
mask: var(--icon-url, none) center / contain no-repeat;
}
.auth-input-wrap:focus-within::before {
background-color: var(--color-accent);
}
.auth-input-wrap--user {
--icon-url: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/><circle cx='12' cy='7' r='4'/></svg>");
}
.auth-input-wrap--mail {
--icon-url: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='2' y='4' width='20' height='16' rx='2'/><path d='m2 7 10 6 10-6'/></svg>");
}
.auth-input-wrap--lock {
--icon-url: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='11' width='18' height='11' rx='2'/><path d='M7 11V7a5 5 0 0 1 10 0v4'/></svg>");
}
/* — Password show/hide toggle — */
.auth-pw-toggle {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.auth-pw-toggle::before {
content: "";
width: 18px;
height: 18px;
background-color: var(--color-text-muted);
transition: background-color 0.2s ease;
--eye: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z'/><circle cx='12' cy='12' r='3'/></svg>");
-webkit-mask: var(--eye) center / contain no-repeat;
mask: var(--eye) center / contain no-repeat;
}
.auth-pw-toggle:hover::before {
background-color: var(--color-accent);
}
.auth-pw-toggle[aria-pressed="true"]::before {
--eye: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M9.9 4.2A9 9 0 0 1 12 4c6.5 0 10 7 10 7a13 13 0 0 1-2 2.7M6.6 6.6A13 13 0 0 0 2 11s3.5 7 10 7a9 9 0 0 0 3.6-.7'/><path d='m2 2 20 20'/></svg>");
}
/* — Progressive disclosure of the magic-link form — */
.auth-magic-toggle {
display: block;
width: 100%;
margin-top: var(--space-1);
padding: var(--space-2);
background: none;
border: none;
color: var(--color-accent-text);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
cursor: pointer;
text-align: center;
}
.auth-magic-toggle:hover {
text-decoration: underline;
}
.auth-magic-reveal {
margin-top: var(--space-3);
}
.auth-magic-reveal:not(.hidden) {
animation: langPickerSlideDown 0.2s ease;
}
/* — Live password-match feedback (sits inside the confirm field group) — */
.auth-match {
margin-top: var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
display: none;
align-items: center;
gap: var(--space-1-5);
}
.auth-match.show {
display: flex;
}
.auth-match::before {
content: "";
width: 14px;
height: 14px;
flex-shrink: 0;
background-color: currentColor;
-webkit-mask: var(--match-icon) center / contain no-repeat;
mask: var(--match-icon) center / contain no-repeat;
}
.auth-match--ok {
color: var(--color-success-text);
--match-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M20 6 9 17l-5-5'/></svg>");
}
.auth-match--bad {
color: var(--color-error-text);
--match-icon: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>");
}
/* "Caps Lock is on" hint under a password field (toggled by auth.js). */
.auth-caps-warning {
display: flex;
align-items: center;
gap: var(--space-1-5);
margin-top: var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--color-warning-orange-text);
}
@@ -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);
}
}
@@ -0,0 +1,65 @@
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 15px;
font-size: var(--text-base);
color: var(--color-text-medium);
gap: var(--space-0-5);
}
.breadcrumb-item {
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-sm);
border: 2px solid transparent;
transition:
background 0.15s,
color 0.15s;
}
.breadcrumb-link {
cursor: pointer;
color: var(--color-text-muted);
}
.breadcrumb-link.drop-target {
background-color: var(--color-warning-ring);
border: 2px dashed var(--color-warning-border);
}
.breadcrumb-link:hover {
text-decoration: underline;
color: var(--color-accent);
background: var(--color-accent-bg);
}
.breadcrumb-current {
font-weight: var(--weight-semibold);
color: var(--color-text-black);
cursor: default;
}
.breadcrumb-separator {
margin: 0 var(--space-1);
color: var(--color-text-faint);
font-size: var(--text-xs);
user-select: none;
}
.breadcrumb-home {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
}
.breadcrumb-home i {
font-size: var(--text-xs);
}
.breadcrumb-home.breadcrumb-link:hover {
background: var(--color-accent-ring);
}
+259
View File
@@ -0,0 +1,259 @@
.btn {
padding: var(--space-3) var(--space-6);
border-radius: var(--radius-2xl);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-base);
font-weight: var(--weight-medium);
gap: var(--space-2);
transition: all 0.2s ease;
}
.btn i {
font-size: 15px;
}
.btn-primary {
background: var(--color-accent-gradient);
color: var(--color-danger-text);
box-shadow: 0 4px 15px var(--color-accent-shadow);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--color-accent-shadow-lg);
}
.btn-primary:active {
transform: translateY(0);
box-shadow: 0 2px 10px var(--color-accent-shadow);
}
.btn-secondary {
background-color: var(--color-bg-hover);
color: var(--color-text-secondary);
border: 2px solid var(--color-border);
}
.btn-secondary:hover {
background-color: var(--color-bg-input-alt);
border-color: var(--color-border-medium);
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--color-shadow-sm);
}
.btn-secondary:active {
transform: translateY(0);
background-color: var(--color-border);
}
.btn-danger {
background: var(--color-danger-gradient);
color: var(--color-danger-text);
box-shadow: 0 4px 15px var(--color-danger-ring);
}
.btn-danger:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px var(--color-danger-ring-lg);
}
.btn-danger:active {
transform: translateY(0);
box-shadow: 0 2px 10px var(--color-danger-ring);
}
/* ── State matrix: focus / disabled / loading ───────────────── */
/* Keyboard focus ring (explicit so the gradient variants get a crisp ring;
mirrors the global a11y baseline). */
.btn:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
/* Disabled — dimmed, no hover lift, non-interactive. */
.btn:disabled,
.btn[disabled],
.btn.is-disabled {
opacity: 0.5;
cursor: not-allowed;
box-shadow: none;
transform: none;
filter: none;
pointer-events: none;
}
/* Loading — hide the label, show an inline spinner, block interaction.
Toggle with the `.is-loading` class or `aria-busy="true"`. */
.btn.is-loading,
.btn[aria-busy="true"] {
position: relative;
color: transparent;
pointer-events: none;
}
.btn.is-loading::after,
.btn[aria-busy="true"]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid var(--color-on-accent);
border-top-color: transparent;
border-radius: var(--radius-full);
animation: spin var(--spin-duration) linear infinite;
}
/* The secondary button's text isn't white — tint its spinner to the text. */
.btn-secondary.is-loading::after,
.btn-secondary[aria-busy="true"]::after {
border-color: var(--color-text-secondary);
border-top-color: transparent;
}
/* View Toggle Buttons */
.view-toggle {
display: flex;
gap: var(--space-0-5);
padding: 3px;
background-color: var(--color-bg-muted);
border-radius: var(--radius-xl);
border: 1px solid var(--color-border);
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
background-color: transparent;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
color: var(--color-text-faint);
font-size: var(--text-base);
transition: all 0.2s ease;
}
.toggle-btn:hover {
background-color: var(--color-border);
color: var(--color-text-subtle);
}
.toggle-btn.active {
background-color: var(--color-border);
color: var(--color-accent);
box-shadow: 0 1px 3px var(--color-shadow);
}
.toggle-btn i {
pointer-events: none;
}
/* ── Group-by selector (inside .view-toggle) ────────────── */
.view-toggle-separator {
width: 1px;
height: 20px;
background: var(--color-border-medium);
align-self: center;
margin: 0 var(--space-0-5);
}
.view-toggle-separator.hidden {
display: none;
}
.group-by-selector {
display: flex;
align-items: center;
position: relative;
}
.group-by-selector.hidden {
display: none;
}
.group-by-btn.active {
color: var(--color-accent);
}
/* Sort direction button — rotate the SVG icon when order is reversed */
.sort-dir-btn .oxi-icon {
transition: transform 0.2s ease;
}
.sort-dir-btn.active .oxi-icon {
transform: rotate(180deg);
}
/* Active label shown inline next to the icon */
.group-by-label {
display: none;
font-size: 0.78rem;
font-weight: var(--weight-semibold);
white-space: nowrap;
}
/* When a group-by is selected the label has text — expand the button to fit */
.group-by-btn:has(.group-by-label:not(:empty)) {
width: auto;
padding: 0 var(--space-2);
gap: 5px;
}
.group-by-btn:has(.group-by-label:not(:empty)) .group-by-label {
display: inline;
}
.group-by-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 200;
min-width: 140px;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px var(--color-shadow);
padding: var(--space-1);
display: flex;
flex-direction: column;
gap: var(--space-0-5);
}
.group-by-menu.hidden {
display: none;
}
.group-by-option {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1-5) var(--space-2-5);
border: none;
background: transparent;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.85rem;
color: var(--color-text);
text-align: left;
width: 100%;
}
.group-by-option:hover {
background: var(--color-border);
}
.group-by-option.active {
color: var(--color-accent);
font-weight: var(--weight-semibold);
}
+113
View File
@@ -0,0 +1,113 @@
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-bg-surface);
}
::-webkit-scrollbar-thumb {
background: var(--color-accent);
border-radius: 3px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--color-accent) var(--color-bg-surface);
}
/* Content area */
.content-area {
flex-grow: 1;
padding: var(--space-5) var(--gutter);
overflow-y: scroll;
scrollbar-gutter: stable;
}
/* Phones: tighten the shared gutter and let the actions bar wrap. */
@media (max-width: 640px) {
:root {
--gutter: var(--space-4);
}
/* Mobile uses overlay scrollbars — don't reserve a phantom gutter. */
.content-area {
scrollbar-gutter: auto;
}
.actions-bar {
flex-wrap: wrap;
height: auto;
gap: var(--space-2);
}
}
.page-title {
font-size: var(--text-2xl);
font-weight: var(--weight-bold);
margin-bottom: var(--space-5);
color: var(--color-text);
}
.page-sticky-header {
position: sticky;
margin: 0px;
padding: var(--space-2-5) 0px;
top: -20px; /* due to padding top of content-area */
background-color: var(--color-bg-page);
z-index: 100; /* ensure header is above image preview */
}
.actions-bar {
display: flex;
justify-content: space-between;
margin: 0 0 var(--space-3);
height: 60px;
padding: var(--space-2-5);
}
.action-buttons {
display: flex;
flex: auto;
gap: var(--space-3);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-muted);
width: 100%;
min-height: 320px;
grid-column: 1 / -1;
}
.empty-state p {
margin: 0;
max-width: 42ch;
color: var(--color-text-muted);
}
/* First paragraph acts as the title. */
.empty-state p:first-of-type {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
}
/* Call-to-action button spacing. */
.empty-state .btn {
margin-top: var(--space-4);
}
/* invisible element, permits building of drag element without altering display */
.drag-preview {
position: absolute;
top: -9999px;
left: -9999px;
pointer-events: none;
width: 360px;
}
@@ -0,0 +1,17 @@
/* File-manager page shell — layout concerns specific to the file-manager section.
* Item rendering (grid cards, list rows, drag ghost) lives in resourceList.css. */
.files-container {
padding-top: 3px; /* cards animate on hover; sticky header has positive z-index */
}
/* Rubber band / lasso selection rectangle */
.selection-rect {
position: fixed;
border: 1.5px solid var(--primary-color, var(--color-card-drop-border));
background-color: var(--color-card-drop-tint);
pointer-events: none;
z-index: 1000;
border-radius: 3px;
display: none;
}
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);
}
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background: linear-gradient(180deg, var(--color-sidebar-bg-from) 0%, var(--color-sidebar-bg-to) 100%);
color: var(--color-sidebar-text-active);
display: flex;
flex-direction: column;
height: 100%;
flex-shrink: 0;
box-shadow: 2px 0 12px var(--color-shadow-md);
transition: transform var(--motion-slow) var(--ease-emphasized);
}
/* Sidebar overlay for mobile */
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-sidebar-overlay);
z-index: 998;
opacity: 0;
transition: opacity 0.3s ease;
}
.sidebar-overlay.active {
display: block;
opacity: 1;
}
/* Mobile responsive styles */
@media (max-width: 768px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 999;
transform: translateX(-100%);
}
.sidebar.open {
transform: translateX(0);
}
[dir="rtl"] .sidebar {
left: auto;
right: 0;
transform: translateX(100%);
}
[dir="rtl"] .sidebar.open {
transform: translateX(0);
}
}
.logo-container {
padding: 22px var(--space-5);
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-sidebar-separator);
margin-bottom: var(--space-2);
text-decoration: none;
color: inherit;
}
.logo {
width: 40px;
height: 40px;
background: var(--color-sidebar-logo-gradient);
border-radius: var(--radius-2xl);
display: flex;
align-items: center;
justify-content: center;
margin-right: var(--space-3);
box-shadow: 0 3px 10px var(--color-sidebar-shadow);
transition:
transform 0.2s,
box-shadow 0.2s;
[dir="rtl"] & {
margin-left: var(--space-3);
margin-right: unset;
}
}
.logo:hover {
transform: scale(1.05);
box-shadow: 0 4px 14px var(--color-sidebar-shadow-lg);
}
.logo svg {
width: 22px;
height: 22px;
fill: var(--color-sidebar-text-active);
}
.app-name {
font-size: 19px;
font-weight: var(--weight-bold);
color: var(--color-sidebar-text-active);
letter-spacing: 0.3px;
}
.nav-menu {
display: flex;
flex-direction: column;
flex-grow: 1;
padding: var(--space-2) var(--space-3);
gap: var(--space-0-5);
}
.nav-item {
display: flex;
align-items: center;
width: 100%;
padding: 11px var(--space-3-5);
border-radius: var(--radius-xl);
cursor: pointer;
color: var(--color-sidebar-text);
font-size: 14.5px;
font-weight: var(--weight-medium);
/* Button resets — .nav-item is a <button> (keyboard-operable nav). */
font-family: inherit;
text-align: left;
appearance: none;
background: none;
transition: all 0.2s ease;
position: relative;
border: none;
border-left: 3px solid transparent;
[dir="rtl"] & {
border-left: none;
border-right: 3px solid transparent;
}
}
.nav-item:hover {
background-color: var(--color-sidebar-hover-bg);
color: var(--color-sidebar-text-hover);
}
.nav-item.active {
background-color: var(--color-sidebar-active-bg);
color: var(--color-sidebar-text-active);
border-left-color: var(--color-accent);
font-weight: var(--weight-semibold);
[dir="rtl"] & {
border-left-color: transparent;
border-right-color: var(--color-accent);
}
}
.nav-item i,
.nav-item .nav-icon,
.nav-item .oxi-icon {
margin-right: var(--space-3-5);
width: 20px;
height: 20px;
text-align: center;
font-size: var(--text-md);
transition:
color 0.2s,
transform 0.2s;
flex-shrink: 0;
[dir="rtl"] & {
margin-left: var(--space-3-5);
margin-right: unset;
}
}
/* Colored icons per section */
.nav-item:nth-child(1) i,
.nav-item:nth-child(1) .oxi-icon {
color: var(--color-cal-1);
} /* Files - orange */
.nav-item:nth-child(2) i,
.nav-item:nth-child(2) .oxi-icon {
color: var(--color-cal-2);
} /* Shared - blue */
.nav-item:nth-child(3) i,
.nav-item:nth-child(3) .oxi-icon {
color: var(--color-cal-3);
} /* Recent - teal */
.nav-item:nth-child(4) i,
.nav-item:nth-child(4) .oxi-icon {
color: var(--color-cal-4);
} /* Favorites - gold */
.nav-item:nth-child(5) i,
.nav-item:nth-child(5) .oxi-icon {
color: var(--color-cal-5);
} /* Photos - pink */
.nav-item:nth-child(6) i,
.nav-item:nth-child(6) .oxi-icon {
color: var(--color-cal-6);
} /* Trash - red */
/* Colourful icons at rest, but the ACTIVE item is always accent — one
coherent active state instead of a colour that changes per item.
Same specificity as the per-child rest rules, so source order (this comes
after them) makes it win for the active item. */
.nav-item.active i,
.nav-item.active .oxi-icon {
color: var(--color-accent);
}
.nav-item:hover i,
.nav-item:hover .oxi-icon {
transform: scale(1.1);
}
/* Storage indicator */
.storage-container {
margin: auto var(--space-3) var(--space-4) var(--space-3);
background: var(--color-sidebar-storage-bg);
border: 1px solid var(--color-sidebar-storage-border);
border-radius: var(--radius-2xl);
padding: var(--space-4);
}
.storage-title {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-1-5);
margin-bottom: var(--space-3);
font-size: var(--text-sm);
font-weight: var(--weight-semibold);
color: var(--color-sidebar-storage-text);
letter-spacing: 0.3px;
}
.storage-bar {
height: 6px;
background-color: var(--color-sidebar-storage-bar);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-2-5);
}
.storage-fill {
height: 100%;
background: var(--color-sidebar-progress);
border-radius: 3px;
width: 0%;
transition: width 0.8s ease;
}
.storage-info {
text-align: center;
font-size: 11.5px;
color: var(--color-sidebar-storage-faint);
font-weight: var(--weight-normal);
}
+93
View File
@@ -0,0 +1,93 @@
/* ============================================================
* Reusable loading-skeleton primitive.
*
* A `.skeleton` element pulses; modifiers shape it into lines,
* list rows (mirroring the file-list grid), or grid tiles. Used to
* hold layout during first load instead of a centred spinner that
* makes the list flash empty. Honors reduced-motion via the global
* guard in base/a11y.css.
*
* (shareModal's bespoke .smd-skeleton predates this and can migrate
* onto it when its loader is next touched.)
* ============================================================ */
.skeleton {
background: var(--color-bg-muted);
border-radius: var(--radius-md);
animation: skeletonPulse 1.4s ease-in-out infinite;
}
.skeleton-line {
height: 14px;
}
.skeleton-line--short {
width: 40%;
}
.skeleton-line--medium {
width: 65%;
}
.skeleton-line--full {
width: 100%;
}
/* A loading row shaped like a file-list row (inherits the list's grid
columns; falls back to icon + name + meta when used standalone). */
.skeleton-row {
display: grid;
grid-template-columns: var(--files-list-columns, 36px 1fr 90px);
column-gap: var(--space-3);
align-items: center;
padding: var(--space-3) 15px;
border-bottom: 1px solid var(--color-border-xfaint);
}
/* A square loading tile for the photo/file grid. */
.skeleton-tile {
aspect-ratio: 1;
width: 100%;
border-radius: var(--radius-lg);
}
/* File-grid loading card — mirrors the real grid card (4:3 thumbnail tile +
name line + meta line). The card frame itself doesn't pulse; its inner
`.skeleton` elements do. Dropped straight into `.files-grid-view`. */
.skeleton-card {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 2px solid var(--color-border);
border-radius: var(--radius-2xl);
}
.skeleton-card .skeleton-thumb {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--radius-lg);
margin-bottom: var(--space-2);
}
.skeleton-card .skeleton-line {
align-self: center;
}
/* Small leading icon block for list-row skeletons. */
.skeleton-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
}
@keyframes skeletonPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
+257
View File
@@ -0,0 +1,257 @@
/* Main content */
.main-content {
flex-grow: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Top bar */
.top-bar {
height: 70px;
min-height: 70px;
max-height: 70px;
background-color: var(--color-bg-surface);
border-bottom: 1px solid var(--color-border);
display: flex;
align-items: center;
padding: 0 var(--gutter);
justify-content: space-between;
flex-shrink: 0;
}
/* Sidebar toggle button (hidden on desktop) */
.sidebar-toggle {
display: none;
background: none;
border: none;
padding: var(--space-2-5);
cursor: pointer;
color: var(--color-text-secondary);
border-radius: var(--radius-lg);
transition:
background-color 0.2s,
color 0.2s;
margin-right: var(--space-3);
flex-shrink: 0;
}
.sidebar-toggle:hover {
background-color: var(--color-border);
border-color: var(--color-border-medium);
}
.sidebar-toggle i {
font-size: var(--text-xl);
}
/* Mobile search toggle button — hidden on desktop */
.search-toggle-btn {
display: none;
background: none;
border: none;
padding: var(--space-2-5);
cursor: pointer;
color: var(--color-text-secondary);
border-radius: var(--radius-lg);
font-size: var(--text-lg);
transition:
background-color 0.2s,
color 0.2s;
flex-shrink: 0;
}
.search-toggle-btn:hover {
background-color: var(--color-border);
}
/* Back button inside expanded mobile search — hidden everywhere by default */
.search-back-btn {
display: none;
background: none;
border: none;
padding: var(--space-2-5);
cursor: pointer;
color: var(--color-text-secondary);
border-radius: var(--radius-lg);
font-size: var(--text-lg);
transition:
background-color 0.2s,
color 0.2s;
flex-shrink: 0;
}
.search-back-btn:hover {
background-color: var(--color-border);
color: var(--color-text-heading);
}
/* Slot wrapping .search-container + #path-tooltip — it owns the flex slot
so the tooltip can absolutely overlay the search bar via inset:0 instead
of viewport-fixed positioning. */
.search-slot {
flex-grow: 1;
max-width: 600px;
margin-right: var(--space-5);
position: relative;
display: flex;
align-items: center;
[dir="rtl"] & {
margin-left: var(--space-5);
margin-right: unset;
}
}
.search-container {
flex-grow: 1;
position: relative;
display: flex;
align-items: center;
}
.search-container input {
width: 100%;
padding: var(--space-3) 50px var(--space-3) var(--space-11);
border-radius: var(--radius-2xl);
border: 2px solid var(--color-border);
background-color: var(--color-bg-input);
font-size: var(--text-base);
height: 46px;
color: var(--color-text-heading);
transition: all 0.2s ease;
}
.search-container input:hover {
border-color: var(--color-border-medium);
background-color: var(--color-bg-surface);
}
.search-container input:focus {
outline: none;
border-color: var(--color-accent);
background-color: var(--color-bg-surface);
box-shadow: 0 0 0 4px var(--color-accent-ring);
}
.search-container input::placeholder {
color: var(--color-text-placeholder);
}
.search-icon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-placeholder);
font-size: var(--text-md);
pointer-events: none;
transition: color 0.2s ease;
[dir="rtl"] & {
right: 16px;
left: unset;
}
}
.search-container input:focus + .search-icon,
.search-container:focus-within .search-icon {
color: var(--color-accent);
}
.search-button {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
background: var(--color-accent-gradient);
color: var(--color-danger-text);
border: none;
border-radius: var(--radius-xl);
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
box-shadow: 0 2px 8px var(--color-accent-shadow);
[dir="rtl"] & {
left: 6px;
right: unset;
}
}
.search-button:hover {
transform: translateY(-50%) scale(1.05);
box-shadow: 0 4px 12px var(--color-accent-shadow-lg);
}
.search-button:active {
transform: translateY(-50%) scale(0.98);
}
.search-button i {
font-size: var(--text-base);
}
.user-controls {
display: flex;
align-items: center;
gap: var(--space-3);
}
/* Mobile responsive styles for top bar */
@media (max-width: 768px) {
.top-bar {
padding: 0 var(--space-4);
}
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
}
/* Hide full search slot on mobile by default */
.search-slot {
display: none;
}
/* Show icon-only search button; push it to the right before user-controls */
.search-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
/* Expanded mobile search: full-width overlay of the top bar */
.top-bar--search-active .search-back-btn {
display: flex;
align-items: center;
justify-content: center;
}
.top-bar--search-active .search-slot {
display: flex;
flex-grow: 1;
max-width: none;
margin: 0;
}
.top-bar--search-active .search-icon {
display: none;
}
.top-bar--search-active .search-container input {
padding-left: var(--space-4);
}
.top-bar--search-active .sidebar-toggle,
.top-bar--search-active .search-toggle-btn,
.top-bar--search-active .user-controls {
display: none;
}
}
@@ -0,0 +1,78 @@
/* Upload Dropdown — ported from static/css/components/uploadDropdown.css.
* Font Awesome `<i>` icons are emitted as `<svg class="oxi-icon">` by Icon.svelte,
* so the icon selectors target `.oxi-icon` instead of `i`. */
.upload-dropdown {
position: relative;
display: inline-block;
}
.upload-dropdown .btn-primary {
display: flex;
align-items: center;
gap: var(--space-1-5);
}
.upload-dropdown-menu {
display: block;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 200px;
background: var(--color-bg-surface);
border-radius: var(--radius-2xl);
box-shadow: 0 8px 30px var(--color-shadow-md);
border: 1px solid var(--color-border);
z-index: 1000;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.upload-dropdown-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-4);
border: none;
background: none;
color: var(--color-text-dark);
font-size: var(--text-base);
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
}
.upload-dropdown-item:hover {
background: var(--color-border-light);
}
.upload-dropdown-item:active {
background: var(--color-border);
}
.upload-dropdown-item .oxi-icon {
width: 20px;
text-align: center;
color: var(--color-text-subtle);
font-size: 15px;
}
.upload-dropdown-item:first-child {
border-bottom: 1px solid var(--color-border-light);
}
.upload-caret {
margin-left: var(--space-1);
font-size: var(--text-xs);
}
+294
View File
@@ -0,0 +1,294 @@
/* User Menu */
.user-menu-wrapper {
position: relative;
}
.user-avatar-btn {
background: none;
border: none;
border-radius: 50%;
padding: 0;
cursor: pointer;
transition: transform var(--motion-base) var(--ease-standard);
display: flex;
align-items: center;
justify-content: center;
}
/* Premium hover: ONE soft accent ring hugging the avatar + a gentle warm glow
+ a subtle pop — replaces the old heavy double halo (button border ring with
a gap + a second avatar ring). */
.user-avatar-btn:hover {
transform: scale(1.05);
}
.user-avatar-btn:hover .user-vignette__avatar {
box-shadow:
0 0 0 3px var(--color-accent-ring),
0 3px 12px -2px var(--color-accent-shadow);
}
/* Menu open: a slightly firmer ring (same clean single-ring language). */
.user-menu-wrapper.open .user-avatar-btn .user-vignette__avatar {
box-shadow: 0 0 0 3px var(--color-accent-ring-strong);
}
/* ── Avatar vignette overrides ────────────────────────────────────────────── */
/* The toolbar button and dropdown header mount avatar-only userVignette
components. Sizing is owned by userVignette.css (--menu / --xl variants);
the rules below add the decoration that is specific to this context. */
.user-avatar-btn .user-vignette__avatar {
letter-spacing: 0.5px;
user-select: none;
/* Animate the ring/glow smoothly in AND out (transition on the base, not
:hover). */
transition: box-shadow var(--motion-base) var(--ease-standard);
}
.user-menu-header .user-vignette__avatar {
letter-spacing: 0.5px;
box-shadow: 0 4px 12px var(--color-accent-shadow);
}
.user-menu {
display: none;
position: absolute;
top: calc(100% + 10px);
right: 0;
left: auto;
width: 300px;
background: var(--color-bg-surface);
border-radius: var(--radius-3xl);
box-shadow:
0 12px 40px var(--color-shadow-md),
0 0 0 1px var(--color-shadow-xs);
z-index: 2000;
overflow: hidden;
animation: userMenuIn 0.2s ease-out;
}
.user-menu-wrapper.open .user-menu {
display: block;
}
[dir="rtl"] .user-menu {
right: auto;
left: 0;
}
@keyframes userMenuIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.user-menu-header {
display: flex;
align-items: center;
gap: var(--space-3-5);
padding: var(--space-5) var(--space-5) var(--space-4);
background: var(--color-user-menu-header-bg);
border-bottom: 1px solid var(--color-user-menu-header-border);
}
/* The header vignette (xl, name + email) fills the available width. */
.user-menu-header .user-vignette {
flex: 1;
min-width: 0;
}
/* Increase name prominence relative to the base vignette style. */
.user-menu-header .user-vignette__name {
font-size: 15px;
font-weight: var(--weight-semibold);
color: var(--color-text-heading);
}
.user-menu-header .user-vignette__email {
font-size: 12.5px;
margin-top: 1px;
}
.user-menu-storage {
padding: var(--space-3-5) var(--space-5);
}
.user-menu-storage-label {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-xs);
font-weight: var(--weight-semibold);
color: var(--color-text-subtle);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: var(--space-2);
}
.user-menu-storage-label i {
font-size: var(--text-2xs);
color: var(--color-text-faint);
}
.user-menu-storage-bar {
height: 6px;
background: var(--color-border-light);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-1-5);
}
.user-menu-storage-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent-second));
border-radius: 3px;
width: 0%;
transition: width 0.5s ease;
}
.user-menu-storage-text {
font-size: 11.5px;
color: var(--color-text-faint);
}
.user-menu-divider {
height: 1px;
background: var(--color-border-light);
margin: var(--space-1) 0;
}
.user-menu-item {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) var(--space-5);
border: none;
background: none;
color: var(--color-text-dark);
font-size: var(--text-base);
cursor: pointer;
transition: background 0.15s ease;
text-align: left;
}
.user-menu-item:hover,
.user-menu-item:focus-visible {
background: var(--color-bg-hover);
}
.user-menu-item i {
width: 20px;
text-align: center;
font-size: 15px;
color: var(--color-text-subtle);
}
/* The appearance row is a plain container, not a button — the inner
* segmented control captures the clicks. Match the height of other
* .user-menu-item rows so the row reads as part of the same list. */
.user-menu-item--theme {
cursor: default;
}
.user-menu-item--theme:hover {
background: transparent;
}
/* Light / Like OS / Dark — three-option pill, active option highlighted
* with the accent colour. Sits at the right edge of the row. */
.theme-segmented {
margin-left: auto;
display: inline-flex;
background: var(--color-bg-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-full);
padding: var(--space-0-5);
gap: var(--space-0-5);
}
.theme-segmented__opt {
width: 28px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-subtle);
border-radius: var(--radius-full);
cursor: pointer;
font-size: var(--text-2xs);
padding: 0;
transition:
background 0.15s ease,
color 0.15s ease;
}
.theme-segmented__opt:hover {
color: var(--color-text);
}
.theme-segmented__opt--active {
background: var(--color-accent);
color: var(--color-danger-text);
}
.theme-segmented__opt--active:hover {
color: var(--color-danger-text);
}
.user-menu-admin {
color: var(--color-primary);
}
.user-menu-admin i {
color: var(--color-info-blue);
}
.user-menu-admin:hover {
background: var(--color-info-bg-alt);
}
.user-menu-role-badge {
padding: 0 var(--space-5) var(--space-1);
}
.role-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-2xs);
font-weight: var(--weight-semibold);
padding: var(--space-0-5) var(--space-2-5);
border-radius: var(--radius-xl);
}
.role-badge-admin {
background: var(--color-info-surface);
color: var(--color-primary);
}
.role-badge i {
font-size: 10px;
}
.user-menu-logout {
color: var(--color-danger-alt);
margin-bottom: var(--space-1);
}
.user-menu-logout i {
color: var(--color-danger-alt);
}
.user-menu-logout:hover {
background: var(--color-danger-lighter);
}
+27
View File
@@ -0,0 +1,27 @@
/** Display helpers shared across list views. */
/**
* 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 {
if (!iconClass) return 'file';
const token = iconClass
.split(/\s+/)
.find((c) => c.startsWith('fa-') && c !== 'fa-fw' && c !== 'fa-lg');
return token ? token.slice(3) : 'file';
}
/** Format a timestamp (epoch seconds/ms or ISO-8601 string) as a local date. */
export function formatDate(value: number | string | null | undefined): string {
if (value === null || value === undefined) return '';
let d: Date;
if (typeof value === 'number') {
// Heuristic: seconds vs milliseconds.
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
d = new Date(value);
}
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
+15
View File
@@ -0,0 +1,15 @@
/** Error-handling helpers shared across pages and components. */
import { ui } from '$lib/stores/ui.svelte';
/** Normalise an unknown thrown value into a human-readable message. */
export function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
/**
* Raise an error toast for a caught value — the canonical catch-block handler.
* Replaces the repeated `ui.notify(e instanceof Error ? e.message : String(e), 'error')`.
*/
export function errorToast(e: unknown): void {
ui.notify(errorMessage(e), 'error');
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { formatBytes } from './format';
describe('formatBytes', () => {
it('formats zero', () => {
expect(formatBytes(0)).toBe('0 B');
});
it('formats bytes without decimals', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('formats kilobytes and megabytes', () => {
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB');
});
it('handles invalid input', () => {
expect(formatBytes(-1)).toBe('—');
expect(formatBytes(NaN)).toBe('—');
});
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Format a byte count as a human-readable size string.
* Seed utility to validate the unit-test harness; the full formatter set is
* ported from static/js/core/formatters.js in Phase 1.
*/
export function formatBytes(bytes: number, decimals = 1): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return `${value.toFixed(i === 0 ? 0 : decimals)} ${units[i]}`;
}
@@ -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();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* 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 recognised route.
*/
export function hashUrlToPath(hash: string): string | null {
if (!hash.startsWith('#/')) return null;
const raw = hash.slice(1); // drop the '#'
const [pathPart] = raw.split('?');
const segs = pathPart.split('/').filter(Boolean); // e.g. ['files','folder','<id>']
if (segs.length === 0 || segs[0] === 'files') {
// #/ , #/files , #/files/folder/<id>/<id>...
if (segs[1] === 'folder') return `/files/${segs.slice(2).join('/')}`;
return '/files';
}
switch (segs[0]) {
case 'shared':
return '/shared';
case 'sharedwithme':
return '/shared-with-me';
case 'recent':
return '/recent';
case 'favorites':
return '/favorites';
case 'trash':
return '/trash';
case 'photos':
return '/photos';
case 'music':
return '/music';
default:
return null;
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Downscale an image File to a data URI for avatar upload. Ported from the
* original utils/imageResize.js: never upscales, caps the longest edge at
* `maxDim`, prefers WebP with a JPEG fallback.
*/
function readAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result as string);
fr.onerror = () => reject(fr.error);
fr.readAsDataURL(file);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('image load failed'));
img.src = src;
});
}
export async function resizeImageToDataUrl(file: File, maxDim = 512): Promise<string> {
const dataUrl = await readAsDataUrl(file);
const img = await loadImage(dataUrl);
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
const w = Math.round(img.width * scale);
const h = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return dataUrl;
ctx.drawImage(img, 0, 0, w, h);
const webp = canvas.toDataURL('image/webp', 0.85);
return webp.startsWith('data:image/webp') ? webp : canvas.toDataURL('image/jpeg', 0.85);
}

Some files were not shown because too many files have changed in this diff Show More