init new frontend

This commit is contained in:
Bradley Nelson
2026-06-17 17:06:30 -06:00
parent b8a0018785
commit daa3010458
114 changed files with 32716 additions and 119 deletions
+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 the legacy version 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 } : {};
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Admin endpoints — ported from views/admin/admin.js. Covers users + plugins
* (the core management surfaces). Settings (OIDC/storage/SMTP), storage
* migration, and plugin logs/retention are not yet ported — see the admin route.
*/
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;
email: string;
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');
}
// ── Plugins ─────────────────────────────────────────────────────────────
export interface PluginInfo {
id: string;
name: string;
version?: string;
enabled: boolean;
description?: string;
}
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');
}
+62
View File
@@ -0,0 +1,62 @@
/**
* 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 async function logout(): Promise<void> {
await apiFetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: '{}'
});
}
+26
View File
@@ -0,0 +1,26 @@
/** 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;
}
export async function lookupDeviceCode(code: string): Promise<DeviceInfo> {
const res = await apiFetch(`/api/auth/device/verify?code=${encodeURIComponent(code)}`, {
credentials: 'same-origin'
});
if (!res.ok) throw new Error(`device lookup failed: ${res.status}`);
return (await res.json()) as DeviceInfo;
}
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}`);
}
@@ -0,0 +1,41 @@
/** Favorites endpoints — ported from favoritesModel.js + features/library. */
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 FavoritesResourceItem {
resource_type: ItemType;
favorited_at: string;
resource: ResourceBody;
}
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}`);
}
+60
View File
@@ -0,0 +1,60 @@
/** 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}`);
}
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`;
}
export function fileThumbnailUrl(fileId: string): string {
return `/api/files/${fileId}/thumbnail/preview`;
}
+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`;
}
+59
View File
@@ -0,0 +1,59 @@
/** Sharing (ReBAC grants) endpoints — ported from model/grants.js. */
import { apiFetch } from '$lib/api/client';
import type { ItemType } from '$lib/api/types';
import type { ResourceBody, ResourcePage } from './resources';
export interface IncomingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
granted_by?: string;
granted_at?: string;
role?: string;
}
export interface OutgoingGrantItem {
resource_type: ItemType;
resource: ResourceBody;
subject?: string;
first_shared_at?: string;
role?: string;
}
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>;
}
+70
View File
@@ -0,0 +1,70 @@
/** Group (ReBAC) endpoints — ported from model/groups.js. */
import { apiFetch, apiJson } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
const JSON_HEADERS = { 'Content-Type': 'application/json' };
const enc = encodeURIComponent;
export interface GroupItem {
id: string;
name: string;
description?: string | null;
member_count?: number;
}
export interface GroupMember {
user_id?: string;
group_id?: string;
email?: string;
name?: 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}`);
}
/** The list endpoint may return an array or `{ groups | items, total }`. */
export async function listGroups(limit = 50, offset = 0, q?: string): Promise<GroupItem[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
if (q) params.set('q', q);
const data = await apiJson<GroupItem[] | { groups?: GroupItem[]; items?: GroupItem[] }>(
`/api/groups?${params}`,
{ credentials: 'same-origin' }
);
if (Array.isArray(data)) return data;
return data.groups ?? data.items ?? [];
}
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 });
}
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');
}
+103
View File
@@ -0,0 +1,103 @@
/** 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;
}
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;
}
export async function renamePlaylist(playlistId: string, name: string): Promise<void> {
const res = await apiFetch(`/api/playlists/${playlistId}`, {
method: 'PUT',
credentials: 'same-origin',
headers: { ...JSON_HEADERS, ...getCsrfHeaders() },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error(`rename playlist failed: ${res.status}`);
}
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}`);
}
+26
View File
@@ -0,0 +1,26 @@
/** Photos timeline endpoint — ported from features/library/photos.js. */
import { apiFetch } from '$lib/api/client';
import type { FileItem } from '$lib/api/types';
export interface PhotoPage {
items: FileItem[];
nextCursor: string | null;
}
/**
* 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 FileItem[];
const cursor = res.headers.get('X-Next-Cursor');
return {
items: items ?? [],
nextCursor: cursor && items && items.length >= limit ? cursor : null
};
}
+45
View File
@@ -0,0 +1,45 @@
/** 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';
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) throw new 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): 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}`);
}
+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,42 @@
/**
* Shared cursor-pagination helper for the favorites/recent/trash "resources"
* endpoints, which all take the same query params. Ported from the legacy
* 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>;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 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' };
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' };
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`;
}
+37
View File
@@ -0,0 +1,37 @@
/** Trash endpoints — ported from trashModel.js + views/trash. */
import { apiFetch } from '$lib/api/client';
import { getCsrfHeaders } from '$lib/api/csrf';
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);
}
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}`);
}
+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;
}
+232
View File
@@ -0,0 +1,232 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { logout } from '$lib/api/endpoints/auth';
import Icon from '$lib/icons/Icon.svelte';
import { i18n, SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { theme, type Theme } from '$lib/stores/theme.svelte';
import { formatBytes } from '$lib/utils/format';
let { children }: { children: Snippet } = $props();
interface NavLink {
href: string;
label: string;
icon: string;
admin?: boolean;
}
const LINKS: NavLink[] = [
{ href: '/files', label: t('nav.files', 'Files'), icon: 'folder' },
{ href: '/shared', label: t('nav.shared', 'Shared'), icon: 'globe' },
{
href: '/shared-with-me',
label: t('nav.shared_with_me', 'Shared with me'),
icon: 'user-group'
},
{ href: '/recent', label: t('nav.recent', 'Recent'), icon: 'clock' },
{ href: '/favorites', label: t('nav.favorites', 'Favorites'), icon: 'star' },
{ href: '/photos', label: t('nav.photos', 'Photos'), icon: 'images' },
{ href: '/music', label: t('nav.music', 'Music'), icon: 'music' },
{ href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash' },
{ href: '/groups', label: t('nav.groups', 'Groups'), icon: 'users', admin: true },
{ href: '/admin', label: t('admin.title', 'Admin'), icon: 'cog', admin: true }
];
const isAdmin = $derived(session.user?.role === 'admin');
const visibleLinks = $derived(LINKS.filter((l) => !l.admin || isAdmin));
function active(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
}
let sidebarOpen = $state(false);
const THEMES: Theme[] = ['light', 'auto', 'dark'];
const storagePct = $derived(
session.user && session.user.storage_quota_bytes > 0
? Math.min(100, (session.user.storage_used_bytes / session.user.storage_quota_bytes) * 100)
: 0
);
async function onLogout() {
try {
await logout();
} catch {
/* clear locally regardless */
}
session.reset();
await goto('/login');
}
</script>
<div
class="sidebar-overlay"
class:active={sidebarOpen}
onclick={() => (sidebarOpen = false)}
role="presentation"
></div>
<div class="sidebar" class:open={sidebarOpen}>
<a href="/files" class="logo-container">
<div class="logo">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="app-name">OxiCloud</div>
</a>
<nav class="nav-menu" aria-label={t('nav.primary', 'Primary')}>
{#each visibleLinks as link (link.href)}
<a
class="nav-item"
class:active={active(link.href)}
href={link.href}
onclick={() => (sidebarOpen = false)}
>
<Icon name={link.icon} />
<span>{link.label}</span>
</a>
{/each}
</nav>
{#if session.user}
<div class="storage-container">
<div class="storage-title">
<Icon name="database" /> <span>{t('storage.title', 'Storage')}</span>
</div>
<div class="storage-bar">
<div class="storage-fill" style:width="{storagePct}%"></div>
</div>
<div class="storage-info">
{#if session.user.storage_quota_bytes > 0}
{formatBytes(session.user.storage_used_bytes)} / {formatBytes(
session.user.storage_quota_bytes
)}
{:else}
{formatBytes(session.user.storage_used_bytes)} {t('storage.used', 'used')}
{/if}
</div>
</div>
{/if}
</div>
<div class="main-content">
<div class="top-bar">
<button
class="sidebar-toggle"
aria-label="Toggle navigation menu"
aria-expanded={sidebarOpen}
onclick={() => (sidebarOpen = !sidebarOpen)}
>
<Icon name="bars" />
</button>
<div class="user-controls">
<div class="seg" role="group" aria-label={t('settings.theme', 'Theme')}>
{#each THEMES as th (th)}
<button
class="seg__btn"
aria-pressed={theme.current === th}
onclick={() => theme.set(th)}
>
{#if th === 'light'}<Icon name="sun" />{:else if th === 'dark'}<Icon
name="moon"
/>{:else}A{/if}
</button>
{/each}
</div>
<select
class="locale-select"
aria-label={t('settings.language', 'Language')}
value={i18n.locale}
onchange={(e) => setLocale(e.currentTarget.value as Locale)}
>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
{#if session.user}
<span class="user-name" title={session.user.email}>
<Icon name="user" />
{session.user.username || session.user.email}
</span>
<button class="logout" onclick={onLogout}>{t('nav.logout', 'Log out')}</button>
{/if}
</div>
</div>
<div class="content-area">
{@render children()}
</div>
</div>
<style>
/* The app shell relies on the global layout CSS (sidebar/topbar/content);
these scoped rules cover only the controls unique to the rewrite. */
:global(body) {
display: flex;
}
.user-controls {
margin-left: auto;
}
.seg {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.seg__btn {
padding: 0.3rem 0.5rem;
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
min-width: 2rem;
}
.seg__btn[aria-pressed='true'] {
background: var(--color-accent);
color: var(--color-on-accent);
}
.locale-select {
padding: 0.3rem 0.4rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.user-name {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--color-text-secondary);
font-size: var(--text-sm);
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.logout {
padding: 0.4rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import { iconNameFromClass } from '$lib/utils/display';
interface Props {
name: string;
iconClass?: string;
subtitle?: string;
date?: string;
actions?: Snippet;
}
let { name, iconClass, subtitle, date, actions }: Props = $props();
</script>
<li class="row">
<span class="row__icon"><Icon name={iconNameFromClass(iconClass)} /></span>
<span class="row__main">
<span class="row__name" title={name}>{name}</span>
{#if subtitle}<span class="row__sub" title={subtitle}>{subtitle}</span>{/if}
</span>
{#if date}<span class="row__date">{date}</span>{/if}
{#if actions}<span class="row__actions">{@render actions()}</span>{/if}
</li>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-md);
}
.row:hover {
background: var(--color-bg-hover);
}
.row__icon {
font-size: 1.25rem;
color: var(--color-text-muted);
flex: none;
}
.row__main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.row__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
}
.row__sub {
font-size: 0.8125rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.row__date {
font-size: 0.8125rem;
color: var(--color-text-muted);
flex: none;
}
.row__actions {
display: flex;
gap: 0.25rem;
flex: none;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Snippet } from '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();
function close() {
open = false;
onclose?.();
}
function onkeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
</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}>
{#if title}
<header class="modal__header">
<h2 class="modal__title">{title}</h2>
<button class="modal__close" aria-label="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;
}
.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;
}
.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);
}
</style>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { t } from '$lib/i18n/index.svelte';
interface Props {
loading: boolean;
error?: string | null;
empty: boolean;
emptyText?: string;
hasMore?: boolean;
onloadmore?: () => void;
toolbar?: Snippet;
children: Snippet;
}
let {
loading,
error = null,
empty,
emptyText,
hasMore = false,
onloadmore,
toolbar,
children
}: Props = $props();
</script>
<section class="rl">
{#if toolbar}
<div class="rl__toolbar">{@render toolbar()}</div>
{/if}
{#if error}
<p class="rl__error" role="alert">{error}</p>
{:else if loading && empty}
<p class="rl__status">{t('common.loading', 'Loading…')}</p>
{:else if empty}
<p class="rl__status">{emptyText ?? t('common.empty', 'Nothing here yet.')}</p>
{:else}
<ul class="rl__list">
{@render children()}
</ul>
{#if hasMore}
<button class="rl__more" onclick={onloadmore} disabled={loading}>
{loading ? t('common.loading', 'Loading…') : t('common.load_more', 'Load more')}
</button>
{/if}
{/if}
</section>
<style>
.rl {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.rl__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.rl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rl__status,
.rl__error {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.rl__error {
color: var(--color-danger-text);
}
.rl__more {
align-self: center;
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte';
</script>
<div class="toaster" role="region" aria-live="polite" aria-label="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="Dismiss" onclick={() => ui.dismiss(toast.id)}>
×
</button>
</div>
{/each}
</div>
<style>
.toaster {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 1000;
max-width: min(92vw, 24rem);
}
.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);
}
.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>
+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');
});
});
+202
View File
@@ -0,0 +1,202 @@
/**
* 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 legacy 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];
const STORAGE_KEY = 'oxicloud-locale';
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');
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;
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;
+53
View File
@@ -0,0 +1,53 @@
/**
* Files view state — replaces the navigation-related fields of the legacy `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';
export type ViewMode = 'grid' | 'list';
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 legacy `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 legacy 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
};
+32
View File
@@ -0,0 +1,32 @@
/**
* Transient UI state — toasts now; cross-component dialog targets are added as
* the views that need them land (Phases 2–4). Component-local state is preferred;
* only state that must cross component boundaries belongs here.
*/
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
export interface Toast {
id: number;
message: string;
kind: ToastKind;
}
class UiStore {
toasts = $state<Toast[]>([]);
#seq = 0;
notify(message: string, kind: ToastKind = 'info', timeoutMs = 4000): number {
const id = ++this.#seq;
this.toasts = [...this.toasts, { id, message, kind }];
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);
}
}
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('./legacy.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);
}
}
+13
View File
@@ -0,0 +1,13 @@
/* Vendored layout/component CSS ported verbatim from the original static/css.
* These are token-based and global; the Svelte components emit the same class
* names and DOM so the new app matches the original look. Kept byte-faithful
* (linters ignore this dir) — restyle via tokens in variables.css, not here. */
@import url('./legacy/sidebar.css');
@import url('./legacy/topbar.css');
@import url('./legacy/content.css');
@import url('./legacy/buttons.css');
@import url('./legacy/breadcrumb.css');
@import url('./legacy/fileManager.css');
@import url('./legacy/resourceList.css');
@import url('./legacy/skeleton.css');
@import url('./legacy/auth.css');
+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,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
+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;
}
}
+27
View File
@@ -0,0 +1,27 @@
/** Display helpers shared across list views. */
/**
* Map a legacy `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' });
}
+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]}`;
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { legacyHashToPath } from './legacyHash';
describe('legacyHashToPath', () => {
it('maps root and files', () => {
expect(legacyHashToPath('#/')).toBe('/files');
expect(legacyHashToPath('#/files')).toBe('/files');
});
it('maps folder deep links to the new path', () => {
expect(legacyHashToPath('#/files/folder/abc')).toBe('/files/abc');
expect(legacyHashToPath('#/files/folder/abc/def')).toBe('/files/abc/def');
});
it('maps the named sections', () => {
expect(legacyHashToPath('#/shared')).toBe('/shared');
expect(legacyHashToPath('#/sharedwithme')).toBe('/shared-with-me');
expect(legacyHashToPath('#/recent')).toBe('/recent');
expect(legacyHashToPath('#/favorites')).toBe('/favorites');
expect(legacyHashToPath('#/trash')).toBe('/trash');
expect(legacyHashToPath('#/photos')).toBe('/photos');
expect(legacyHashToPath('#/music')).toBe('/music');
});
it('ignores query strings in the hash', () => {
expect(legacyHashToPath('#/recent?foo=bar')).toBe('/recent');
});
it('returns null for non-legacy or unknown hashes', () => {
expect(legacyHashToPath('')).toBeNull();
expect(legacyHashToPath('#section')).toBeNull();
expect(legacyHashToPath('#/unknown')).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Translate a legacy hash route (`#/...` from the vanilla app) into the new
* SvelteKit path, so old bookmarks and external links keep working.
*
* Returns the new pathname, or null when the hash isn't a legacy route.
*/
export function legacyHashToPath(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;
}
}
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { onMount } from 'svelte';
import '$lib/styles/app.css';
import AppShell from '$lib/components/AppShell.svelte';
import Toaster from '$lib/components/Toaster.svelte';
import { session } from '$lib/stores/session.svelte';
import { legacyHashToPath } from '$lib/utils/legacyHash';
let { children } = $props();
// Routes reachable without an authenticated session.
const PUBLIC_PREFIXES = ['/login', '/device', '/s/', '/nextcloud'];
function isPublic(pathname: string): boolean {
return PUBLIC_PREFIXES.some((p) => pathname === p || pathname.startsWith(p));
}
let ready = $state(false);
onMount(async () => {
// Redirect legacy `#/...` bookmarks to the new path before anything else.
if (typeof location !== 'undefined' && location.hash.startsWith('#/')) {
const mapped = legacyHashToPath(location.hash);
if (mapped) await goto(mapped, { replaceState: true });
}
await session.load();
ready = true;
});
// Guard: once the session is known, bounce unauthenticated users off
// protected routes. Runs client-side only (ssr=false).
$effect(() => {
if (!ready) return;
const path = page.url.pathname;
if (!session.isAuthenticated && !isPublic(path)) {
void goto(`/login?redirect=${encodeURIComponent(path)}`, { replaceState: true });
}
});
</script>
{#if isPublic(page.url.pathname)}
{@render children()}
{:else if ready && session.isAuthenticated}
<AppShell {children} />
{:else if ready}
{@render children()}
{:else}
<div class="app-loading" aria-busy="true">Loading…</div>
{/if}
<Toaster />
<style>
.app-loading {
display: grid;
place-items: center;
min-height: 100vh;
color: var(--color-text-muted);
}
</style>
+6
View File
@@ -0,0 +1,6 @@
// Pure client-rendered SPA: no SSR, no prerendering. The static adapter emits a
// single `index.html` fallback that the Rust web layer serves for every client
// route (including deep links like /s/<token> and /files/<path>).
export const ssr = false;
export const prerender = false;
export const trailingSlash = 'never';
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// The app root redirects to the primary files view.
onMount(() => {
void goto('/files', { replaceState: true });
});
</script>
<p>Loading…</p>
+452
View File
@@ -0,0 +1,452 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
createUser,
deletePlugin,
deleteUser,
listPlugins,
listUsers,
resetUserPassword,
setPluginEnabled,
setUserActive,
setUserQuota,
setUserRole,
type PluginInfo
} from '$lib/api/endpoints/admin';
import type { User } from '$lib/api/types';
import Modal from '$lib/components/Modal.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
const PAGE_SIZE = 25;
let tab = $state<'users' | 'plugins'>('users');
// Users
let users = $state<User[]>([]);
let total = $state(0);
let pageIndex = $state(0);
let usersError = $state<string | null>(null);
let createOpen = $state(false);
let newUser = $state({ username: '', email: '', password: '', role: 'user', quotaGb: 5 });
// Plugins
let plugins = $state<PluginInfo[]>([]);
let pluginsAvailable = $state(true);
let pluginsError = $state<string | null>(null);
async function loadUsers() {
usersError = null;
try {
const page = await listUsers(PAGE_SIZE, pageIndex * PAGE_SIZE);
users = page.users;
total = page.total;
} catch (e) {
usersError = e instanceof Error ? e.message : String(e);
}
}
async function loadPlugins() {
pluginsError = null;
try {
const res = await listPlugins();
pluginsAvailable = res.available;
plugins = res.plugins;
} catch (e) {
pluginsError = e instanceof Error ? e.message : String(e);
}
}
function reportError(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
async function toggleRole(u: User) {
const role = u.role === 'admin' ? 'user' : 'admin';
if (!confirm(t('admin.confirm_role', { role }, 'Change role to {{role}}?'))) return;
try {
await setUserRole(u.id, role);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function toggleActive(u: User) {
try {
await setUserActive(u.id, !u.active);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function changeQuota(u: User) {
const gb = prompt(t('admin.quota_prompt', 'Quota in GB (0 = unlimited)'));
if (gb === null) return;
try {
await setUserQuota(u.id, Math.round(Number(gb) * 1024 ** 3));
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function resetPw(u: User) {
const pw = prompt(t('admin.new_password_prompt', 'New password'));
if (!pw) return;
try {
await resetUserPassword(u.id, pw);
ui.notify(t('admin.password_reset', 'Password reset'), 'success');
} catch (e) {
reportError(e);
}
}
async function removeUser(u: User) {
if (!confirm(t('admin.confirm_delete_user', { name: u.email }, 'Delete user {{name}}?')))
return;
try {
await deleteUser(u.id);
await loadUsers();
} catch (e) {
reportError(e);
}
}
async function submitCreate(e: SubmitEvent) {
e.preventDefault();
try {
await createUser({
username: newUser.username,
email: newUser.email,
password: newUser.password,
role: newUser.role,
quota_bytes: Math.round(newUser.quotaGb * 1024 ** 3)
});
createOpen = false;
newUser = { username: '', email: '', password: '', role: 'user', quotaGb: 5 };
await loadUsers();
} catch (err) {
reportError(err);
}
}
async function togglePlugin(p: PluginInfo) {
try {
await setPluginEnabled(p.id, !p.enabled);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
async function removePlugin(p: PluginInfo) {
if (!confirm(t('admin.confirm_delete_plugin', { name: p.name }, 'Delete plugin {{name}}?')))
return;
try {
await deletePlugin(p.id);
await loadPlugins();
} catch (e) {
reportError(e);
}
}
function changePage(delta: number) {
const next = pageIndex + delta;
if (next < 0 || next * PAGE_SIZE >= total) return;
pageIndex = next;
void loadUsers();
}
onMount(() => {
void loadUsers();
void loadPlugins();
});
</script>
<svelte:head><title>{t('admin.title', 'Admin')} · OxiCloud</title></svelte:head>
<main class="admin">
<h1>{t('admin.title', 'Admin')}</h1>
<div class="tabs" role="tablist">
<button role="tab" aria-selected={tab === 'users'} onclick={() => (tab = 'users')}>
{t('admin.users', 'Users')}
</button>
<button role="tab" aria-selected={tab === 'plugins'} onclick={() => (tab = 'plugins')}>
{t('admin.plugins', 'Plugins')}
</button>
</div>
{#if tab === 'users'}
<div class="bar">
<button class="btn btn--primary" onclick={() => (createOpen = true)}>
{t('admin.create_user', 'Create user')}
</button>
</div>
{#if usersError}
<p class="status status--error">{usersError}</p>
{:else}
<table class="table">
<thead>
<tr>
<th>{t('admin.user', 'User')}</th>
<th>{t('admin.role', 'Role')}</th>
<th>{t('admin.status', 'Status')}</th>
<th>{t('admin.quota', 'Quota')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each users as u (u.id)}
<tr>
<td>
<div class="user-cell">
<strong>{u.username || u.email}</strong>
<span class="muted">{u.email}</span>
</div>
</td>
<td>{u.role}</td>
<td>{u.active ? t('admin.active', 'Active') : t('admin.inactive', 'Inactive')}</td>
<td>
{u.storage_quota_bytes > 0 ? formatBytes(u.storage_quota_bytes) : '∞'}
</td>
<td class="actions">
<button class="link-btn" onclick={() => toggleRole(u)}
>{t('admin.role', 'Role')}</button
>
<button class="link-btn" onclick={() => toggleActive(u)}>
{u.active ? t('admin.deactivate', 'Deactivate') : t('admin.activate', 'Activate')}
</button>
<button class="link-btn" onclick={() => changeQuota(u)}
>{t('admin.quota', 'Quota')}</button
>
<button class="link-btn" onclick={() => resetPw(u)}
>{t('admin.reset_pw', 'Reset pw')}</button
>
<button class="link-btn link-btn--danger" onclick={() => removeUser(u)}>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
<div class="pager">
<button class="btn" disabled={pageIndex === 0} onclick={() => changePage(-1)}>‹</button>
<span>{pageIndex + 1} / {Math.max(1, Math.ceil(total / PAGE_SIZE))}</span>
<button
class="btn"
disabled={(pageIndex + 1) * PAGE_SIZE >= total}
onclick={() => changePage(1)}>›</button
>
</div>
{/if}
{:else if !pluginsAvailable}
<p class="status">{t('admin.plugins_disabled', 'The plugin subsystem is disabled.')}</p>
{:else if pluginsError}
<p class="status status--error">{pluginsError}</p>
{:else if plugins.length === 0}
<p class="status">{t('admin.no_plugins', 'No plugins installed.')}</p>
{:else}
<table class="table">
<thead>
<tr>
<th>{t('admin.plugin', 'Plugin')}</th>
<th>{t('admin.version', 'Version')}</th>
<th>{t('admin.status', 'Status')}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each plugins as p (p.id)}
<tr>
<td>
<div class="user-cell">
<strong>{p.name}</strong>
{#if p.description}<span class="muted">{p.description}</span>{/if}
</div>
</td>
<td>{p.version ?? '—'}</td>
<td>{p.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}</td>
<td class="actions">
<button class="link-btn" onclick={() => togglePlugin(p)}>
{p.enabled ? t('admin.disable', 'Disable') : t('admin.enable', 'Enable')}
</button>
<button class="link-btn link-btn--danger" onclick={() => removePlugin(p)}>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</main>
<Modal bind:open={createOpen} title={t('admin.create_user', 'Create user')}>
<form id="create-user-form" onsubmit={submitCreate} class="form">
<label
><span>{t('admin.username', 'Username')}</span>
<input bind:value={newUser.username} required /></label
>
<label
><span>{t('admin.email', 'Email')}</span>
<input type="email" bind:value={newUser.email} required /></label
>
<label
><span>{t('admin.password', 'Password')}</span>
<input type="password" bind:value={newUser.password} required /></label
>
<label
><span>{t('admin.role', 'Role')}</span>
<select bind:value={newUser.role}>
<option value="user">user</option>
<option value="admin">admin</option>
</select></label
>
<label
><span>{t('admin.quota_gb', 'Quota (GB)')}</span>
<input type="number" min="0" bind:value={newUser.quotaGb} /></label
>
</form>
{#snippet footer()}
<button class="btn" onclick={() => (createOpen = false)}>{t('common.cancel', 'Cancel')}</button>
<button class="btn btn--primary" type="submit" form="create-user-form">
{t('common.create', 'Create')}
</button>
{/snippet}
</Modal>
<style>
.admin {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
}
.tabs button {
padding: 0.5rem 1rem;
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-primary);
}
.bar {
display: flex;
justify-content: flex-end;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
text-align: left;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.875rem;
}
.user-cell {
display: flex;
flex-direction: column;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.875rem;
}
.form input,
.form select {
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.status {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.status--error {
color: var(--color-danger-text);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>
+177
View File
@@ -0,0 +1,177 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { decideDevice, lookupDeviceCode, type DeviceInfo } from '$lib/api/endpoints/device';
import { t } from '$lib/i18n/index.svelte';
type Step = 'code' | 'loading' | 'review' | 'approved' | 'denied' | 'error';
let code = $state(page.url.searchParams.get('code') ?? '');
let step = $state<Step>('code');
let info = $state<DeviceInfo | null>(null);
let errorText = $state('');
let busy = $state(false);
async function lookup(e?: SubmitEvent) {
e?.preventDefault();
if (!code) return;
step = 'loading';
errorText = '';
try {
info = await lookupDeviceCode(code);
step = 'review';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
step = 'error';
}
}
async function decide(action: 'approve' | 'deny') {
busy = true;
try {
await decideDevice(code, action);
step = action === 'approve' ? 'approved' : 'denied';
} catch (err) {
errorText = err instanceof Error ? err.message : String(err);
step = 'error';
} finally {
busy = false;
}
}
onMount(() => {
if (code) void lookup();
});
</script>
<svelte:head><title>{t('device.title', 'Device verification')} · OxiCloud</title></svelte:head>
<main class="device">
<div class="device__card">
<h1>{t('device.title', 'Device verification')}</h1>
{#if step === 'code'}
<form onsubmit={lookup}>
<label class="device__field">
<span>{t('device.enter_code', 'Enter the code shown on your device')}</span>
<input bind:value={code} autocomplete="off" inputmode="text" />
</label>
<button type="submit" disabled={!code}>{t('device.continue', 'Continue')}</button>
</form>
{:else if step === 'loading'}
<p>{t('common.loading', 'Loading…')}</p>
{:else if step === 'review'}
<dl class="device__info">
<dt>{t('device.client', 'Application')}</dt>
<dd>{info?.client_name || t('device.unknown', 'Unknown')}</dd>
<dt>{t('device.scopes', 'Access')}</dt>
<dd>{info?.scopes || 'all'}</dd>
</dl>
<div class="device__actions">
<button class="device__deny" disabled={busy} onclick={() => decide('deny')}>
{t('device.deny', 'Deny')}
</button>
<button class="device__approve" disabled={busy} onclick={() => decide('approve')}>
{t('device.approve', 'Approve')}
</button>
</div>
{:else if step === 'approved'}
<p class="device__ok">
{t('device.approved', 'Device approved. You can return to your device.')}
</p>
{:else if step === 'denied'}
<p>{t('device.denied', 'Device access denied.')}</p>
{:else if step === 'error'}
<p class="device__error" role="alert">{errorText}</p>
<button onclick={() => (step = 'code')}>{t('common.retry', 'Try again')}</button>
{/if}
</div>
</main>
<style>
.device {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.device__card {
width: min(92vw, 24rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.device__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.device__field input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: 1.25rem;
letter-spacing: 0.1em;
text-align: center;
}
.device__info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0;
}
.device__info dt {
color: var(--color-text-muted);
}
.device__info dd {
margin: 0;
color: var(--color-text);
}
.device__actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
button {
padding: 0.5rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.device__approve {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.device__deny {
color: var(--color-danger-text);
}
.device__ok {
color: var(--color-success-text);
}
.device__error {
color: var(--color-danger-text);
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
fetchFavoritesPage,
removeFavorite,
type FavoritesResourceItem
} from '$lib/api/endpoints/favorites';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<FavoritesResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchFavoritesPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function unfavorite(item: FavoritesResourceItem) {
try {
await removeFavorite(item.resource_type, item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.favorites', 'Favorites')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.favorites', 'Favorites')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('favorites.empty', 'No favorites yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.favorited_at)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => unfavorite(item)}>
{t('favorites.remove', 'Remove')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,398 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Icon from '$lib/icons/Icon.svelte';
import {
createFolder,
deleteFolder,
getFolder,
listFolder,
renameFolder,
type FolderListing
} from '$lib/api/endpoints/folders';
import {
deleteFile,
fileDownloadUrl,
fileInlineUrl,
renameFile,
uploadFile
} from '$lib/api/endpoints/files';
import type { FileItem, FolderItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatBytes } from '$lib/utils/format';
import { formatDate, iconNameFromClass } from '$lib/utils/display';
// The URL rest param is the trail of folder ids from home's children down.
// /files → home root; /files/a/b → folder b inside a inside home.
const pathSegments = $derived((page.params.path ?? '').split('/').filter((s) => s.length > 0));
let listing = $state<FolderListing>({ folders: [], files: [] });
let crumbs = $state<Array<{ id: string; name: string }>>([]);
let currentId = $state<string | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
let uploading = $state(false);
let dragOver = $state(false);
async function buildCrumbs(segments: string[]): Promise<Array<{ id: string; name: string }>> {
// Names for each id in the trail; tolerate failures with a fallback label.
const metas = await Promise.all(
segments.map((id) =>
getFolder(id)
.then((f) => ({ id, name: f.name }))
.catch(() => ({ id, name: '…' }))
)
);
return metas;
}
async function load() {
loading = true;
error = null;
try {
// External users have no home folder; send them to shared-with-me.
if (session.isExternalUser && pathSegments.length === 0) {
await goto('/shared-with-me', { replaceState: true });
return;
}
const home = await session.loadHomeFolder();
const folderId = pathSegments.at(-1) ?? home;
if (!folderId) {
error = t('files.no_home', 'No home folder available.');
return;
}
currentId = folderId;
filesStore.currentFolder = folderId;
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
listing = data;
crumbs = trail;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function openFolder(folder: FolderItem) {
goto(`/files/${[...pathSegments, folder.id].join('/')}`);
}
function crumbHref(index: number): string {
return `/files/${pathSegments.slice(0, index + 1).join('/')}`;
}
async function onNewFolder() {
const name = prompt(t('files.new_folder_prompt', 'New folder name'));
if (!name) return;
try {
await createFolder(name, currentId);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
uploading = true;
try {
for (const file of Array.from(input.files)) {
await uploadFile(currentId, file);
}
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
await load();
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
uploading = false;
input.value = '';
}
}
async function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const dropped = e.dataTransfer?.files;
if (!dropped?.length) return;
uploading = true;
try {
for (const file of Array.from(dropped)) await uploadFile(currentId, file);
ui.notify(t('files.uploaded', 'Upload complete'), 'success');
await load();
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
uploading = false;
}
}
async function renameItem(kind: 'file' | 'folder', id: string, current: string) {
const name = prompt(t('files.rename_prompt', 'New name'), current);
if (!name || name === current) return;
try {
if (kind === 'file') await renameFile(id, name);
else await renameFolder(id, name);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function deleteItem(kind: 'file' | 'folder', id: string, name: string) {
if (!confirm(t('files.confirm_delete', { name }, 'Move "{{name}}" to trash?'))) return;
try {
if (kind === 'file') await deleteFile(id);
else await deleteFolder(id);
await load();
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function openFile(file: FileItem) {
window.open(fileInlineUrl(file.id), '_blank', 'noopener');
}
const isEmpty = $derived(listing.folders.length === 0 && listing.files.length === 0);
const viewClass = $derived(
filesStore.viewMode === 'grid' ? 'files-grid-view' : 'files-list-view'
);
// Reload whenever the route path changes.
$effect(() => {
// reference pathSegments so the effect re-runs on navigation
void pathSegments;
void load();
});
</script>
<svelte:head><title>{t('nav.files', 'Files')} · OxiCloud</title></svelte:head>
<div
class="files-page"
class:dropzone-active={dragOver}
role="region"
aria-label={t('nav.files', 'Files')}
ondragover={(e) => {
e.preventDefault();
dragOver = true;
}}
ondragleave={() => (dragOver = false)}
ondrop={onDrop}
>
<div class="page-sticky-header">
<nav class="breadcrumb" aria-label="Breadcrumb">
<a href="/files" class="breadcrumb-item">
<Icon name="folder" />
{session.homeFolderName ?? t('nav.files', 'Files')}
</a>
{#each crumbs as c, i (c.id)}
<span class="breadcrumb-separator">/</span>
<a href={crumbHref(i)} class="breadcrumb-item">{c.name}</a>
{/each}
</nav>
<div class="actions-bar">
<div class="action-buttons">
<button class="btn btn-secondary" onclick={onNewFolder}>
<Icon name="folder-plus" />
{t('files.new_folder', 'New folder')}
</button>
<button class="btn btn-primary" onclick={() => fileInput?.click()} disabled={uploading}>
<Icon name="arrow-up" />
{uploading ? t('files.uploading', 'Uploading…') : t('files.upload', 'Upload')}
</button>
<input bind:this={fileInput} type="file" multiple hidden onchange={onUpload} />
</div>
<div class="view-toggle" role="group" aria-label="View mode">
<button
class="view-toggle-btn"
class:active={filesStore.viewMode === 'grid'}
aria-pressed={filesStore.viewMode === 'grid'}
onclick={() => filesStore.setViewMode('grid')}
>
<Icon name="th" />
</button>
<button
class="view-toggle-btn"
class:active={filesStore.viewMode === 'list'}
aria-pressed={filesStore.viewMode === 'list'}
onclick={() => filesStore.setViewMode('list')}
>
<Icon name="list" />
</button>
</div>
</div>
</div>
{#if error}
<div class="empty-state"><p>{error}</p></div>
{:else if loading && isEmpty}
<div class="empty-state"><p>{t('common.loading', 'Loading…')}</p></div>
{:else if isEmpty}
<div class="empty-state">
<p>{t('files.empty_title', 'This folder is empty')}</p>
<p>{t('files.empty_hint', 'Drop files here or use the Upload button to add files.')}</p>
</div>
{:else}
<div class="files-container">
<div
class={viewClass}
style="--files-list-columns: minmax(200px, 2fr) 120px 110px 140px 110px"
>
<div class="list-header">
<div>{t('files.col_name', 'Name')}</div>
<div>{t('files.col_type', 'Type')}</div>
<div>{t('files.col_size', 'Size')}</div>
<div>{t('files.col_modified', 'Modified')}</div>
<div></div>
</div>
{#each listing.folders as folder (folder.id)}
<div
class="file-item"
role="button"
tabindex="0"
ondblclick={() => openFolder(folder)}
onclick={() => openFolder(folder)}
onkeydown={(e) => e.key === 'Enter' && openFolder(folder)}
>
<div class="name-cell">
<span class="file-icon"><Icon name="folder" /></span>
<span>{folder.name}</span>
</div>
<div class="type-cell">{t('files.folder', 'Folder')}</div>
<div class="size-cell">—</div>
<div class="date-cell">{formatDate(folder.modified_at)}</div>
<div class="grid-meta"></div>
<div class="action-cell">
<button
class="btn-action"
title={t('common.rename', 'Rename')}
onclick={(e) => {
e.stopPropagation();
renameItem('folder', folder.id, folder.name);
}}><Icon name="pen" /></button
>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={(e) => {
e.stopPropagation();
deleteItem('folder', folder.id, folder.name);
}}><Icon name="trash" /></button
>
</div>
</div>
{/each}
{#each listing.files as file (file.id)}
<div
class="file-item"
role="button"
tabindex="0"
ondblclick={() => openFile(file)}
onclick={() => openFile(file)}
onkeydown={(e) => e.key === 'Enter' && openFile(file)}
>
<div class="name-cell">
<span class="file-icon"><Icon name={iconNameFromClass(file.icon_class)} /></span>
<span>{file.name}</span>
</div>
<div class="type-cell">{file.category || t('files.file', 'File')}</div>
<div class="size-cell">{file.size != null ? formatBytes(file.size) : ''}</div>
<div class="date-cell">{formatDate(file.modified_at)}</div>
<div class="grid-meta">
{#if file.size != null}<span class="grid-meta__size">{formatBytes(file.size)}</span
>{/if}
</div>
<div class="action-cell">
<a
class="btn-action"
href={fileDownloadUrl(file.id)}
download
title={t('common.download', 'Download')}
onclick={(e) => e.stopPropagation()}><Icon name="download" /></a
>
<button
class="btn-action"
title={t('common.rename', 'Rename')}
onclick={(e) => {
e.stopPropagation();
renameItem('file', file.id, file.name);
}}><Icon name="pen" /></button
>
<button
class="btn-action btn-action--delete"
title={t('common.delete', 'Delete')}
onclick={(e) => {
e.stopPropagation();
deleteItem('file', file.id, file.name);
}}><Icon name="trash" /></button
>
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.files-page {
min-height: 100%;
}
.files-page.dropzone-active {
outline: 2px dashed var(--color-accent);
outline-offset: -8px;
border-radius: var(--radius-xl);
}
.page-sticky-header {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow: hidden;
flex: none;
}
.view-toggle-btn {
padding: var(--space-2) var(--space-3);
border: none;
background: var(--color-bg-surface);
color: var(--color-text-muted);
cursor: pointer;
}
.view-toggle-btn.active {
background: var(--color-accent);
color: var(--color-on-accent);
}
.action-cell {
display: flex;
gap: var(--space-1);
justify-content: flex-end;
}
.btn-action--delete:hover {
color: var(--color-danger-text);
}
.btn-action {
text-decoration: none;
}
</style>
+306
View File
@@ -0,0 +1,306 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addUserMember,
createGroup,
deleteGroup,
listGroups,
listMembers,
removeGroupMember,
removeUserMember,
renameGroup,
type GroupItem,
type GroupMember
} from '$lib/api/endpoints/groups';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let groups = $state<GroupItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let expandedId = $state<string | null>(null);
let members = $state<GroupMember[]>([]);
async function load() {
loading = true;
error = null;
try {
groups = await listGroups();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
function report(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
async function expand(g: GroupItem) {
if (expandedId === g.id) {
expandedId = null;
return;
}
expandedId = g.id;
try {
members = await listMembers(g.id);
} catch (e) {
report(e);
members = [];
}
}
async function onCreate() {
const name = prompt(t('groups.new_prompt', 'New group name'));
if (!name) return;
try {
await createGroup(name);
await load();
} catch (e) {
report(e);
}
}
async function onRename(g: GroupItem) {
const name = prompt(t('groups.rename_prompt', 'New name'), g.name);
if (!name || name === g.name) return;
try {
await renameGroup(g.id, name);
await load();
} catch (e) {
report(e);
}
}
async function onDelete(g: GroupItem) {
if (!confirm(t('groups.confirm_delete', { name: g.name }, 'Delete group "{{name}}"?'))) return;
try {
await deleteGroup(g.id);
if (expandedId === g.id) expandedId = null;
await load();
} catch (e) {
report(e);
}
}
async function onAddMember(g: GroupItem) {
const userId = prompt(t('groups.add_member_prompt', 'User ID to add'));
if (!userId) return;
try {
await addUserMember(g.id, userId);
members = await listMembers(g.id);
} catch (e) {
report(e);
}
}
async function onRemoveMember(groupId: string, m: GroupMember) {
try {
if (m.user_id) await removeUserMember(groupId, m.user_id);
else if (m.group_id) await removeGroupMember(groupId, m.group_id);
members = await listMembers(groupId);
} catch (e) {
report(e);
}
}
onMount(load);
</script>
<svelte:head><title>{t('nav.groups', 'Groups')} · OxiCloud</title></svelte:head>
<main class="groups">
<header class="groups__head">
<h1>{t('nav.groups', 'Groups')}</h1>
<button class="btn btn--primary" onclick={onCreate}>{t('groups.create', 'Create group')}</button
>
</header>
{#if error}
<p class="status status--error">{error}</p>
{:else if loading}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else if groups.length === 0}
<p class="status">{t('groups.empty', 'No groups yet.')}</p>
{:else}
<ul class="list">
{#each groups as g (g.id)}
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
{g.name}
{#if g.member_count != null}<span class="muted">({g.member_count})</span>{/if}
</button>
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
<button class="link-btn" onclick={() => onAddMember(g)}>
{t('groups.add_member', 'Add member')}
</button>
</div>
{#if members.length === 0}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{:else}
<ul class="members__list">
{#each members as m (m.user_id ?? m.group_id)}
<li>
<span>{m.email ?? m.name ?? m.user_id ?? m.group_id}</span>
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</main>
<style>
.groups {
max-width: 48rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.groups__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.groups__head h1 {
margin: 0;
font-size: 1.5rem;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
}
.group__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
}
.group__name {
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
}
.group__actions {
display: flex;
gap: 0.5rem;
}
.members {
border-top: 1px solid var(--color-border);
padding: 0.75rem;
}
.members__head {
display: flex;
align-items: center;
justify-content: space-between;
}
.members__head h2 {
margin: 0;
font-size: 1rem;
}
.members__list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.members__list li {
display: flex;
align-items: center;
justify-content: space-between;
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.status {
color: var(--color-text-muted);
padding: 2rem 0;
text-align: center;
}
.status--error {
color: var(--color-danger-text);
}
.btn {
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
}
.btn--primary {
background: var(--color-primary);
color: var(--color-text-light);
border-color: transparent;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { login } from '$lib/api/endpoints/auth';
import { t } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
let username = $state('');
let password = $state('');
let error = $state('');
let busy = $state(false);
const redirectTarget = $derived(page.url.searchParams.get('redirect') || '/files');
function csrfCookiePresent(): boolean {
return document.cookie.split('; ').some((c) => c.startsWith('oxicloud_csrf='));
}
async function onsubmit(e: SubmitEvent) {
e.preventDefault();
error = '';
busy = true;
try {
const data = await login(username, password);
// Tokens are HttpOnly cookies set by the server. Verify the browser
// actually accepted them: the non-HttpOnly CSRF cookie must be present.
if (!csrfCookiePresent()) {
error = t(
'auth.cookie_rejected',
'Login succeeded but the browser rejected the session cookie. If you are on HTTP, set OXICLOUD_COOKIE_SECURE=false or use HTTPS.'
);
return;
}
session.user = data.user;
await goto(redirectTarget, { replaceState: true });
} catch (err) {
error = err instanceof Error ? err.message : t('auth.login_error', 'Error logging in');
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>{t('app.title', 'OxiCloud')}</title>
</svelte:head>
<div class="auth-container">
<div class="auth-panel">
<div class="auth-logo">
<div class="auth-logo-icon">
<svg viewBox="120 120 280 280" aria-hidden="true">
<path
d="M345 310c32 0 58-26 58-58s-26-58-58-58c-6.2 0-12 0.9-17.5 2.7C318 166 289 143 255 143c-34.3 0-63.1 22.6-73 53.7C176.9 195.7 171 195 165 195c-32 0-58 26-58 58s26 58 58 58h180z"
/>
</svg>
</div>
<div class="auth-logo-text">OxiCloud</div>
</div>
<h1 class="auth-title">{t('auth.sign_in', 'Sign in')}</h1>
{#if page.url.searchParams.get('source') === 'session_expired'}
<div class="auth-error" style="display: block">
{t('auth.session_expired', 'Your session expired. Please sign in again.')}
</div>
{/if}
{#if error}
<div class="auth-error" style="display: block" role="alert">{error}</div>
{/if}
<form class="auth-form" {onsubmit} novalidate>
<div class="auth-input-group">
<label class="auth-label" for="login-username">
{t('auth.username', 'Username or email')}
</label>
<input
id="login-username"
class="auth-input"
type="text"
bind:value={username}
autocomplete="username"
required
disabled={busy}
/>
</div>
<div class="auth-input-group">
<label class="auth-label" for="login-password">{t('auth.password', 'Password')}</label>
<input
id="login-password"
class="auth-input"
type="password"
bind:value={password}
autocomplete="current-password"
required
disabled={busy}
/>
</div>
<button class="auth-button" type="submit" disabled={busy} aria-busy={busy}>
{busy ? t('auth.signing_in', 'Signing in…') : t('auth.sign_in', 'Sign in')}
</button>
</form>
</div>
</div>
+360
View File
@@ -0,0 +1,360 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fileInlineUrl } from '$lib/api/endpoints/files';
import {
createPlaylist,
deletePlaylist,
listPlaylists,
listTracks,
removeTrack,
reorderTracks,
type Playlist,
type PlaylistItem
} from '$lib/api/endpoints/music';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
let playlists = $state<Playlist[]>([]);
let current = $state<Playlist | null>(null);
let tracks = $state<PlaylistItem[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let nowPlaying = $state<string | null>(null);
// native HTML5 drag-reorder state
let dragIndex = $state<number | null>(null);
async function loadPlaylists() {
loading = true;
error = null;
try {
playlists = await listPlaylists();
if (!current && playlists.length > 0) await select(playlists[0]);
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function select(p: Playlist) {
current = p;
try {
tracks = await listTracks(p.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onCreate() {
const name = prompt(t('music.new_playlist', 'New playlist name'));
if (!name) return;
try {
const p = await createPlaylist(name);
playlists = [...playlists, p];
await select(p);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onDelete(p: Playlist) {
if (!confirm(t('music.confirm_delete', { name: p.name }, 'Delete playlist "{{name}}"?')))
return;
try {
await deletePlaylist(p.id);
playlists = playlists.filter((x) => x.id !== p.id);
if (current?.id === p.id) {
current = playlists[0] ?? null;
tracks = current ? await listTracks(current.id) : [];
}
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function onRemoveTrack(track: PlaylistItem) {
if (!current) return;
try {
await removeTrack(current.id, track.file_id);
tracks = tracks.filter((x) => x.id !== track.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
function onDragStart(i: number) {
dragIndex = i;
}
function onDragOver(e: DragEvent, i: number) {
e.preventDefault();
if (dragIndex === null || dragIndex === i) return;
const next = [...tracks];
const [moved] = next.splice(dragIndex, 1);
next.splice(i, 0, moved);
dragIndex = i;
tracks = next;
}
async function onDrop() {
dragIndex = null;
if (!current) return;
try {
await reorderTracks(
current.id,
tracks.map((tr) => tr.id)
);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
await select(current); // reload server order on failure
}
}
function trackLabel(tr: PlaylistItem): string {
return tr.title || tr.file_name || tr.file_id;
}
onMount(loadPlaylists);
</script>
<svelte:head><title>{t('nav.music', 'Music')} · OxiCloud</title></svelte:head>
<div class="music">
<aside class="music__sidebar">
<div class="music__sidebar-head">
<h1>{t('nav.music', 'Music')}</h1>
<button class="link-btn" onclick={onCreate}>+ {t('music.new', 'New')}</button>
</div>
{#if error}
<p class="status status--error">{error}</p>
{:else if loading && playlists.length === 0}
<p class="status">{t('common.loading', 'Loading…')}</p>
{:else if playlists.length === 0}
<p class="status">{t('music.no_playlists', 'No playlists yet.')}</p>
{:else}
<ul class="playlists">
{#each playlists as p (p.id)}
<li>
<button
class="playlists__item"
class:playlists__item--active={current?.id === p.id}
onclick={() => select(p)}
>
<Icon name="music" class="playlists__icon" />
<span class="playlists__name">{p.name}</span>
<span class="playlists__count">{p.track_count}</span>
</button>
<button
class="link-btn link-btn--danger"
aria-label={t('common.delete', 'Delete')}
onclick={() => onDelete(p)}>×</button
>
</li>
{/each}
</ul>
{/if}
</aside>
<section class="music__main">
{#if current}
<header class="music__main-head">
<h2>{current.name}</h2>
</header>
{#if tracks.length === 0}
<p class="status">{t('music.empty_playlist', 'This playlist has no tracks yet.')}</p>
{:else}
<ul class="tracks">
{#each tracks as track, i (track.id)}
<li
class="track"
draggable="true"
ondragstart={() => onDragStart(i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={onDrop}
ondragend={() => (dragIndex = null)}
>
<span class="track__grip" aria-hidden="true"><Icon name="bars" /></span>
<button class="track__play" onclick={() => (nowPlaying = track.file_id)}>
<Icon name={nowPlaying === track.file_id ? 'pause' : 'play'} />
</button>
<span class="track__title">{trackLabel(track)}</span>
{#if track.artist}<span class="track__artist">{track.artist}</span>{/if}
<button class="link-btn link-btn--danger" onclick={() => onRemoveTrack(track)}>
{t('common.remove', 'Remove')}
</button>
</li>
{/each}
</ul>
{/if}
{#if nowPlaying}
<audio class="music__player" src={fileInlineUrl(nowPlaying)} controls autoplay></audio>
{/if}
{:else}
<p class="status">{t('music.select_playlist', 'Select a playlist.')}</p>
{/if}
</section>
</div>
<style>
.music {
display: grid;
grid-template-columns: 16rem 1fr;
min-height: 100vh;
}
.music__sidebar {
border-right: 1px solid var(--color-border);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.music__sidebar-head,
.music__main-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.music__sidebar-head h1 {
font-size: 1.25rem;
margin: 0;
}
.playlists {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.playlists li {
display: flex;
align-items: center;
}
.playlists__item {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.5rem;
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
border-radius: var(--radius-md);
text-align: left;
}
.playlists__item:hover {
background: var(--color-bg-hover);
}
.playlists__item--active {
background: var(--color-bg-hover);
font-weight: 600;
}
.playlists__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlists__count {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.music__main {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.tracks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.track {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
}
.track__grip {
cursor: grab;
color: var(--color-text-muted);
}
.track__play {
border: none;
background: none;
cursor: pointer;
color: var(--color-primary);
}
.track__title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.track__artist {
color: var(--color-text-muted);
font-size: 0.8125rem;
}
.music__player {
position: sticky;
bottom: 0;
width: 100%;
}
.status {
color: var(--color-text-muted);
padding: 1rem 0;
}
.status--error {
color: var(--color-danger-text);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.8125rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
@media (width <= 48rem) {
.music {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,49 @@
<script lang="ts">
import { page } from '$app/state';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
const reason = $derived(page.url.searchParams.get('reason') ?? '');
</script>
<svelte:head
><title>{t('nextcloud.error_title', 'Something went wrong')} · OxiCloud</title></svelte:head
>
<main class="nc-status">
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
<h1>{t('nextcloud.error_title', 'Something went wrong')}</h1>
<p>
{t(
'nextcloud.error_body',
'The connection could not be completed. Please try again from your application.'
)}
</p>
{#if reason}<p class="nc-status__reason">{reason}</p>{/if}
</main>
<style>
.nc-status {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
padding: 2rem 1rem;
}
:global(.nc-status__icon) {
font-size: 3rem;
}
:global(.nc-status__icon--err) {
color: var(--color-danger-text);
}
.nc-status__reason {
color: var(--color-text-muted);
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import { apiFetch } from '$lib/api/client';
import { t } from '$lib/i18n/index.svelte';
// Nextcloud Login Flow v2. The form does a NATIVE POST to the backend flow
// endpoint so the server drives the redirect handshake — do not intercept it.
// The flow token is hex; reject anything else to prevent action injection.
const token = $derived(page.url.searchParams.get('token') ?? '');
const validToken = $derived(/^[0-9a-fA-F]+$/.test(token));
const formAction = $derived(`/login/v2/flow/${token}`);
let oidcEnabled = $state(false);
let oidcProvider = $state('SSO');
let passwordLoginEnabled = $state(true);
onMount(async () => {
try {
const resp = await apiFetch('/api/auth/oidc/providers');
if (!resp.ok) return;
const info = (await resp.json()) as {
enabled?: boolean;
provider_name?: string;
password_login_enabled?: boolean;
};
if (!info.enabled) return;
oidcEnabled = true;
oidcProvider = info.provider_name || 'SSO';
passwordLoginEnabled = info.password_login_enabled !== false;
} catch {
/* OIDC not available — password-only */
}
});
</script>
<svelte:head><title>{t('app.title', 'OxiCloud')}</title></svelte:head>
<main class="nc">
<div class="nc__card">
<h1>{t('nextcloud.grant_title', 'Grant access')}</h1>
{#if !validToken}
<p class="nc__error">{t('nextcloud.invalid_token', 'Invalid session token.')}</p>
{:else}
{#if passwordLoginEnabled}
<form method="post" action={formAction} class="nc__form">
<label>
<span>{t('auth.username', 'Username or email')}</span>
<input name="user" type="text" autocomplete="username" required />
</label>
<label>
<span>{t('auth.password', 'Password')}</span>
<input name="password" type="password" autocomplete="current-password" required />
</label>
<button type="submit">{t('nextcloud.grant', 'Grant access')}</button>
</form>
{/if}
{#if oidcEnabled}
<div class="nc__oidc">
<a class="nc__sso" href={`/login/v2/flow/${token}/oidc`}>
{t('nextcloud.sign_in_with', { provider: oidcProvider }, 'Sign in with {{provider}}')}
</a>
</div>
{/if}
{/if}
</div>
</main>
<style>
.nc {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1rem;
background: var(--color-bg-page);
}
.nc__card {
width: min(92vw, 22rem);
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}
.nc__form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
}
input {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
button,
.nc__sso {
display: inline-block;
text-align: center;
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.nc__error {
color: var(--color-danger-text);
}
</style>
@@ -0,0 +1,34 @@
<script lang="ts">
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
</script>
<svelte:head><title>{t('nextcloud.success_title', 'Access granted')} · OxiCloud</title></svelte:head
>
<main class="nc-status">
<Icon name="check" class="nc-status__icon nc-status__icon--ok" />
<h1>{t('nextcloud.success_title', 'Access granted')}</h1>
<p>{t('nextcloud.success_body', 'You can now return to your application — it is connected.')}</p>
</main>
<style>
.nc-status {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
text-align: center;
padding: 2rem 1rem;
}
:global(.nc-status__icon) {
font-size: 3rem;
}
:global(.nc-status__icon--ok) {
color: var(--color-success-text);
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchPhotos } from '$lib/api/endpoints/photos';
import { fileInlineUrl, fileThumbnailUrl } from '$lib/api/endpoints/files';
import type { FileItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
let items = $state<FileItem[]>([]);
let cursor = $state<string | null>(null);
let exhausted = $state(false);
let loading = $state(false);
let error = $state<string | null>(null);
let sentinel = $state<HTMLElement | null>(null);
async function loadMore() {
if (loading || exhausted) return;
loading = true;
error = null;
try {
const page = await fetchPhotos(60, cursor);
items = [...items, ...page.items];
cursor = page.nextCursor;
if (!page.nextCursor) exhausted = true;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
exhausted = true;
} finally {
loading = false;
}
}
onMount(() => {
void loadMore();
if (!sentinel) return;
const obs = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
{ rootMargin: '600px' }
);
obs.observe(sentinel);
return () => obs.disconnect();
});
</script>
<svelte:head><title>{t('nav.photos', 'Photos')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.photos', 'Photos')}</h1>
{#if error}
<p class="status status--error" role="alert">{error}</p>
{:else if items.length === 0 && exhausted}
<p class="status">{t('photos.empty', 'No photos yet.')}</p>
{:else}
<ul class="photos">
{#each items as photo (photo.id)}
<li class="photos__cell">
<a href={fileInlineUrl(photo.id)} target="_blank" rel="noreferrer">
<img src={fileThumbnailUrl(photo.id)} alt={photo.name} loading="lazy" decoding="async" />
</a>
</li>
{/each}
</ul>
{/if}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{#if loading}<p class="status">{t('common.loading', 'Loading…')}</p>{/if}
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.photos {
list-style: none;
margin: 0;
padding: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.25rem;
}
.photos__cell {
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-sm);
background: var(--color-bg-muted);
}
.photos__cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.status {
text-align: center;
color: var(--color-text-muted);
padding: 2rem 0;
}
.status--error {
color: var(--color-danger-text);
}
.sentinel {
height: 1px;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { changePassword, updateProfile } from '$lib/api/endpoints/profile';
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
import { ui } from '$lib/stores/ui.svelte';
let givenName = $state('');
let familyName = $state('');
let username = $state('');
let preferredLocale = $state<string>('');
let notifyOnShare = $state(true);
let currentPw = $state('');
let newPw = $state('');
let confirmPw = $state('');
let savingProfile = $state(false);
let savingPassword = $state(false);
function hydrate() {
const u = session.user;
if (!u) return;
givenName = u.given_name ?? '';
familyName = u.family_name ?? '';
username = u.username ?? '';
preferredLocale = u.preferred_locale ?? '';
notifyOnShare = u.notify_on_share;
}
async function saveProfile(e: SubmitEvent) {
e.preventDefault();
savingProfile = true;
try {
const updated = await updateProfile({
given_name: givenName,
family_name: familyName,
username: username || undefined,
preferred_locale: preferredLocale || undefined,
notify_on_share: notifyOnShare
});
session.user = updated;
if (preferredLocale) await setLocale(preferredLocale as Locale);
ui.notify(t('profile.saved', 'Profile saved'), 'success');
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
savingProfile = false;
}
}
async function savePassword(e: SubmitEvent) {
e.preventDefault();
if (newPw !== confirmPw) {
ui.notify(t('profile.password_mismatch', 'Passwords do not match'), 'error');
return;
}
savingPassword = true;
try {
await changePassword(currentPw, newPw);
currentPw = newPw = confirmPw = '';
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
} catch (err) {
ui.notify(err instanceof Error ? err.message : String(err), 'error');
} finally {
savingPassword = false;
}
}
onMount(async () => {
if (!session.loaded) await session.load();
hydrate();
});
</script>
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
<main class="profile">
<h1>{t('nav.profile', 'Profile')}</h1>
{#if session.user}
<p class="profile__email">{session.user.email}</p>
<form class="card" onsubmit={saveProfile}>
<h2>{t('profile.details', 'Profile details')}</h2>
<label>
<span>{t('profile.username', 'Username')}</span>
<input bind:value={username} autocomplete="username" />
</label>
<label>
<span>{t('profile.given_name', 'Given name')}</span>
<input bind:value={givenName} autocomplete="given-name" />
</label>
<label>
<span>{t('profile.family_name', 'Family name')}</span>
<input bind:value={familyName} autocomplete="family-name" />
</label>
<label>
<span>{t('profile.language', 'Language')}</span>
<select bind:value={preferredLocale}>
<option value="">{t('profile.language_auto', 'Automatic')}</option>
{#each SUPPORTED_LOCALES as loc (loc)}
<option value={loc}>{loc}</option>
{/each}
</select>
</label>
<label class="checkbox">
<input type="checkbox" bind:checked={notifyOnShare} />
<span>{t('profile.notify_on_share', 'Email me when something is shared with me')}</span>
</label>
<button type="submit" disabled={savingProfile}>{t('common.save', 'Save')}</button>
</form>
{#if session.user.can_edit_image !== false && session.user.auth_provider === 'local'}
<form class="card" onsubmit={savePassword}>
<h2>{t('profile.change_password', 'Change password')}</h2>
<label>
<span>{t('profile.current_password', 'Current password')}</span>
<input type="password" bind:value={currentPw} autocomplete="current-password" />
</label>
<label>
<span>{t('profile.new_password', 'New password')}</span>
<input type="password" bind:value={newPw} autocomplete="new-password" />
</label>
<label>
<span>{t('profile.confirm_password', 'Confirm new password')}</span>
<input type="password" bind:value={confirmPw} autocomplete="new-password" />
</label>
<button type="submit" disabled={savingPassword}>
{t('profile.update_password', 'Update password')}
</button>
</form>
{/if}
{:else}
<p>{t('common.loading', 'Loading…')}</p>
{/if}
</main>
<style>
.profile {
max-width: 36rem;
margin: 0 auto;
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.profile__email {
margin: -1rem 0 0;
color: var(--color-text-muted);
}
.card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.5rem;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 1.125rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--color-text);
}
label.checkbox {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
input,
select {
padding: 0.5rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
font-size: 1rem;
}
label.checkbox input {
width: auto;
}
button {
align-self: flex-start;
padding: 0.5rem 1.25rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
cursor: pointer;
}
button:disabled {
opacity: 0.6;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<RecentResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchRecentPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function clearAll() {
try {
await clearRecent();
items = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.recent', 'Recent')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.recent', 'Recent')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('recent.empty', 'No recent items.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn" onclick={clearAll}>{t('recent.clear', 'Clear')}</button>
{/if}
{/snippet}
{#each items as item (item.resource.id + item.accessed_at)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.accessed_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
</style>
+294
View File
@@ -0,0 +1,294 @@
<script lang="ts">
import { page } from '$app/state';
import { onMount } from 'svelte';
import Icon from '$lib/icons/Icon.svelte';
import {
getShareContents,
getShareMeta,
shareDownloadUrl,
shareFileUrl,
shareZipUrl,
verifySharePassword,
type ShareListing,
type ShareMeta
} from '$lib/api/endpoints/share';
import { t } from '$lib/i18n/index.svelte';
type State = 'loading' | 'password' | 'expired' | 'file' | 'folder';
const token = $derived(page.params.token ?? '');
let view = $state<State>('loading');
let meta = $state<ShareMeta | null>(null);
let listing = $state<ShareListing | null>(null);
let folderId = $state<string | undefined>(undefined);
let folderName = $state<string>('');
let pwInput = $state('');
let pwError = $state('');
let busy = $state(false);
async function loadMeta() {
view = 'loading';
try {
const r = await getShareMeta(token);
if (r.status === 'password') {
view = 'password';
} else if (r.status === 'expired') {
view = 'expired';
} else {
meta = r.data;
if (r.data.item_type === 'folder') await openFolder(undefined, r.data.item_name);
else view = 'file';
}
} catch {
view = 'expired';
}
}
async function openFolder(id: string | undefined, name: string) {
const r = await getShareContents(token, id);
if (r.status === 'password') {
view = 'password';
return;
}
if (r.status === 'expired') {
view = 'expired';
return;
}
listing = r.data;
folderId = id;
folderName = name;
view = 'folder';
}
async function submitPassword(e: SubmitEvent) {
e.preventDefault();
if (!pwInput) return;
busy = true;
pwError = '';
try {
const ok = await verifySharePassword(token, pwInput);
if (!ok) {
pwError = t('share.bad_password', 'Incorrect password. Please try again.');
return;
}
await loadMeta();
} catch {
pwError = t('share.error', 'Something went wrong. Please try again.');
} finally {
busy = false;
}
}
onMount(loadMeta);
</script>
<svelte:head><title>{meta?.item_name ?? t('share.title', 'Shared')} · OxiCloud</title></svelte:head>
<main class="share">
{#if view === 'loading'}
<p class="share__status">{t('common.loading', 'Loading…')}</p>
{:else if view === 'expired'}
<div class="share__center">
<Icon name="ban" class="share__big-icon" />
<p>{t('share.expired', 'This share link is no longer available.')}</p>
</div>
{:else if view === 'password'}
<form class="share__pw" onsubmit={submitPassword}>
<h1>{t('share.password_title', 'Password required')}</h1>
<input
type="password"
bind:value={pwInput}
placeholder={t('share.password', 'Password')}
disabled={busy}
autocomplete="off"
/>
{#if pwError}<p class="share__error" role="alert">{pwError}</p>{/if}
<button type="submit" disabled={busy}>{t('share.unlock', 'Unlock')}</button>
</form>
{:else if view === 'file'}
<div class="share__center">
<Icon name="file" class="share__big-icon" />
<h1>{meta?.item_name}</h1>
<a class="share__btn" href={shareDownloadUrl(token)} download>
{t('share.download', 'Download')}
</a>
</div>
{:else if view === 'folder' && listing}
<header class="share__header">
<h1>{folderName}</h1>
<div class="share__header-actions">
{#if folderId}
<button class="link-btn" onclick={() => openFolder(undefined, meta?.item_name ?? '')}>
← {t('share.back_to_root', 'Back to share root')}
</button>
{/if}
<a class="share__btn" href={shareZipUrl(token, folderId)} download>
{t('share.download_zip', 'Download ZIP')}
</a>
</div>
</header>
{#if listing.folders.length === 0 && listing.files.length === 0}
<p class="share__status">{t('share.empty_folder', 'This folder is empty.')}</p>
{/if}
{#if listing.folders.length > 0}
<h2 class="share__section">{t('share.folders', 'Folders')}</h2>
<ul class="share__grid">
{#each listing.folders as f (f.id)}
<li>
<button class="card" onclick={() => openFolder(f.id, f.name)}>
<Icon name="folder" class="card__icon" />
<span class="card__name">{f.name}</span>
</button>
</li>
{/each}
</ul>
{/if}
{#if listing.files.length > 0}
<h2 class="share__section">{t('share.files', 'Files')}</h2>
<ul class="share__grid">
{#each listing.files as f (f.id)}
<li>
<a class="card" href={shareFileUrl(token, f.id)} target="_blank" rel="noreferrer">
<Icon name="file" class="card__icon" />
<span class="card__name">{f.name}</span>
</a>
</li>
{/each}
</ul>
{/if}
{/if}
</main>
<style>
.share {
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.share__center {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 4rem 0;
text-align: center;
}
:global(.share__big-icon) {
font-size: 3rem;
color: var(--color-text-muted);
}
.share__status {
text-align: center;
color: var(--color-text-muted);
padding: 3rem 0;
}
.share__pw {
max-width: 22rem;
margin: 4rem auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.share__pw input {
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.share__error {
color: var(--color-danger-text);
font-size: 0.875rem;
margin: 0;
}
.share__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.share__header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.share__section {
font-size: 1rem;
color: var(--color-text-muted);
margin: 1.5rem 0 0.5rem;
}
.share__grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
gap: 0.75rem;
}
.card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 1rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
color: var(--color-text);
cursor: pointer;
text-decoration: none;
}
.card:hover {
background: var(--color-bg-hover);
}
:global(.card__icon) {
font-size: 2rem;
color: var(--color-text-muted);
}
.card__name {
font-size: 0.8125rem;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.share__btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius-md);
background: var(--color-primary);
color: var(--color-text-light);
text-decoration: none;
cursor: pointer;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
}
</style>
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchSharedWithMe, type IncomingGrantItem } from '$lib/api/endpoints/grants';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<IncomingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchSharedWithMe({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared_with_me', 'Shared with me')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared_with_me', 'Shared with me')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared_with_me.empty', 'Nothing has been shared with you yet.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.granted_by
? t('shared_with_me.from', { who: item.granted_by }, 'Shared by {{who}}')
: item.resource.path}
date={formatDate(item.granted_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import { fetchMyShares, type OutgoingGrantItem } from '$lib/api/endpoints/grants';
import { t } from '$lib/i18n/index.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<OutgoingGrantItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchMyShares({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.shared', 'Shared')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.shared', 'Shared')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('shared.empty', "You haven't shared anything yet.")}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.subject
? t('shared.with', { who: item.subject }, 'Shared with {{who}}')
: item.resource.path}
date={formatDate(item.first_shared_at)}
/>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import FileRow from '$lib/components/FileRow.svelte';
import ResourceListShell from '$lib/components/ResourceListShell.svelte';
import {
deleteTrashItem,
emptyTrash,
fetchTrashPage,
restoreTrashItem
} from '$lib/api/endpoints/trash';
import type { TrashResourceItem } from '$lib/api/types';
import { t } from '$lib/i18n/index.svelte';
import { ui } from '$lib/stores/ui.svelte';
import { formatDate } from '$lib/utils/display';
let items = $state<TrashResourceItem[]>([]);
let cursor = $state<string | undefined>(undefined);
let loading = $state(false);
let error = $state<string | null>(null);
async function load(reset = false) {
loading = true;
error = null;
try {
const page = await fetchTrashPage({ cursor: reset ? undefined : cursor });
items = reset ? page.items : [...items, ...page.items];
cursor = page.next_cursor;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
loading = false;
}
}
async function restore(item: TrashResourceItem) {
try {
await restoreTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
ui.notify(t('trash.restored', 'Restored'), 'success');
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purge(item: TrashResourceItem) {
if (!confirm(t('trash.confirm_delete', 'Permanently delete this item?'))) return;
try {
await deleteTrashItem(item.resource.id);
items = items.filter((i) => i.resource.id !== item.resource.id);
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
async function purgeAll() {
if (!confirm(t('trash.confirm_empty', 'Empty the trash? This cannot be undone.'))) return;
try {
await emptyTrash();
items = [];
cursor = undefined;
} catch (e) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
}
onMount(() => load(true));
</script>
<svelte:head><title>{t('nav.trash', 'Trash')} · OxiCloud</title></svelte:head>
<h1 class="page-title">{t('nav.trash', 'Trash')}</h1>
<ResourceListShell
{loading}
{error}
empty={items.length === 0}
emptyText={t('trash.empty', 'Trash is empty.')}
hasMore={!!cursor}
onloadmore={() => load(false)}
>
{#snippet toolbar()}
{#if items.length > 0}
<button class="link-btn link-btn--danger" onclick={purgeAll}>
{t('trash.empty_action', 'Empty trash')}
</button>
{/if}
{/snippet}
{#each items as item (item.resource.id)}
<FileRow
name={item.resource.name}
iconClass={item.resource.icon_class}
subtitle={item.resource.path}
date={formatDate(item.deletion_date)}
>
{#snippet actions()}
<button class="link-btn" onclick={() => restore(item)}>
{t('trash.restore', 'Restore')}
</button>
<button class="link-btn link-btn--danger" onclick={() => purge(item)}>
{t('trash.delete', 'Delete')}
</button>
{/snippet}
</FileRow>
{/each}
</ResourceListShell>
<style>
.page-title {
margin: 0;
padding: 1rem 1rem 0;
font-size: 1.5rem;
color: var(--color-text-heading);
}
.link-btn {
background: none;
border: none;
color: var(--color-primary);
cursor: pointer;
font-size: 0.875rem;
}
.link-btn--danger {
color: var(--color-danger-text);
}
</style>