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
+23 -54
View File
@@ -32,15 +32,7 @@ jobs:
with:
filters: |
frontend:
- 'static/**'
- 'biome.json'
- '.grit'
- '.stylelintrc.json'
- 'jsconfig.json'
# Audit scripts that gate frontend correctness — touch
# them and the frontend job must re-run.
- 'tools/check-missing-translations.py'
- 'tools/check-icons.py'
- 'frontend/**'
backend:
- 'src/**'
- 'Cargo.toml'
@@ -59,56 +51,31 @@ jobs:
- 'src/application/adapters/plugin_user_lifecycle_hook.rs'
frontend-check:
name: Frontend — CSS and JS checks (format, lint, css-rules, types)
name: Frontend — svelte-check, ESLint, Stylelint, Prettier
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Install Biome
uses: biomejs/setup-biome@v2
- name: Run Biome check
run: biome ci static/
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 25
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
# because we are not using package.json
- name: Install Stylelint, TypeScript and plugins
run: |
npm install --global \
stylelint@17 \
postcss@8 \
stylelint-value-no-unknown-custom-properties@6 \
typescript
- name: Install dependencies
run: npm ci
- name: Run Stylelint
run: npx stylelint "static/css/**/*.{css,scss}"
- name: Check (svelte-check + eslint + stylelint + prettier)
run: npm run check
- name: Run TypeScript check
run: tsc -p jsconfig.json --noEmit
- name: Check locale files are at parity with en.json
# Python 3 stdlib only — no setup step needed.
# `--check-only` keeps the CI log terse; failures still surface
# via exit code (the script returns 1 when any non-English
# locale is missing a key present in en.json). Mirrors the
# `--check-only` flag on `tools/check-icons.py` below.
# Run locally without --check-only to see the missing keys.
run: python3 tools/check-missing-translations.py --check-only
- name: Check FA icons referenced in static/ are registered
# `--check-only` skips the Font-Awesome clone and the icons.js
# patch — it just scans `fas fa-<name>` references and diffs
# them against OxiIcons. Exit 1 if any used icon is absent
# from the registry. Run locally without --check-only to
# auto-add missing entries from a checked-out Font-Awesome
# source.
run: python3 tools/check-icons.py --check-only
- name: Unit tests
run: npm run test:unit
rust-fmt:
name: Rustfmt
@@ -268,14 +235,16 @@ jobs:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Build SPA (Vite -> static-dist/)
working-directory: frontend
run: npm ci && npm run build
- run: cargo build --release
# build.rs runs the deconflict pass and js_bundle_validate() — any
# duplicate declaration or parse error in the JS bundle fails here.
- name: Validate JS bundle (node --check)
# belt-and-suspenders: node --check parses the bundle without executing it.
# Catches SyntaxErrors that OXC's parse check inside build.rs would also
# catch, but gives a human-readable error line in the CI log.
run: node --check static-dist/js/app.*.js
- uses: actions/upload-artifact@v4
with:
name: oxicloud-release
+14 -1
View File
@@ -6,6 +6,16 @@ FROM rust:1.96-alpine3.24 AS base
RUN apk --no-cache upgrade && \
apk add --no-cache musl-dev pkgconfig gcc perl make
# ─── Stage 1b: Build the SvelteKit frontend (Vite) ───────────────────────────
# Produces the SPA in /static-dist. `npm ci` is cached unless the lockfile
# changes; the Rust build no longer bundles assets (see build.rs).
FROM node:24-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ─── Stage 2: Cache dependencies ─────────────────────────────────────────────
FROM base AS cacher
WORKDIR /app
@@ -39,6 +49,9 @@ COPY templates templates
# Build with all optimizations (DATABASE_URL only needed at compile-time for sqlx)
ARG DATABASE_URL="postgres://postgres:postgres@localhost/oxicloud"
RUN DATABASE_URL="${DATABASE_URL}" cargo build --release
# The SPA is built by the frontend stage; bring it in for the runtime copy below.
# (build.rs no longer generates static-dist unless OXICLOUD_LEGACY_ASSETS=1.)
COPY --from=frontend /static-dist ./static-dist
# ─── Stage 4: Minimal runtime image ──────────────────────────────────────────
FROM alpine:3.24.0
@@ -71,7 +84,7 @@ COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r//' /usr/local/bin/entrypoint.sh && \
chmod 755 /usr/local/bin/entrypoint.sh
# Copy processed static files (bundled/minified by build.rs in release)
# Copy the built SPA (produced by the Vite frontend stage)
COPY --from=builder --chown=oxicloud:oxicloud /app/static-dist /app/static
# Create storage directory with proper permissions
RUN mkdir -p /app/storage && chown -R oxicloud:oxicloud /app/storage
+9
View File
@@ -40,9 +40,18 @@ fn main() {
println!("cargo:rerun-if-changed=static");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=OXICLOUD_LEGACY_ASSETS");
git_status();
// Post-cutover (Svelte/Vite): the frontend is built by Vite into
// `static-dist/` and the Rust web layer serves it directly — no `include_str!`
// HTML, no Rust-side bundling. The legacy pure-Rust asset pipeline below is
// retained, behind `OXICLOUD_LEGACY_ASSETS=1`, for one-release rollback only.
if env_or("OXICLOUD_LEGACY_ASSETS", "0") != "1" {
return;
}
// ── Guard: Docker cacher stage has no static/ ────────────────────────────
if !static_dir.exists() {
for name in HTML_INCLUDE {
+1 -1
View File
@@ -22,7 +22,7 @@
cargo-audit
# frontend tooling (no root package.json — these are expected as global bins)
nodejs_22
nodejs_24
biome
typescript # provides `tsc`
stylelint
+8
View File
@@ -0,0 +1,8 @@
node_modules/
/build/
/.svelte-kit/
/package-lock.json.bak
.DS_Store
*.local
vite.config.ts.timestamp-*
vite.config.js.timestamp-*
+2
View File
@@ -0,0 +1,2 @@
engine-strict=false
save-exact=false
+1
View File
@@ -0,0 +1 @@
24
+13
View File
@@ -0,0 +1,13 @@
build/
.svelte-kit/
package/
node_modules/
package.json
package-lock.json
src/lib/i18n/locales/
# vendored / generated — kept byte-faithful to their source
src/lib/styles/base/
src/lib/styles/legacy/
src/lib/styles/legacy.css
src/lib/icons/registry.ts
static/locales/
+8
View File
@@ -0,0 +1,8 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+8
View File
@@ -0,0 +1,8 @@
build/
.svelte-kit/
node_modules/
# Ported verbatim from static/css/base — treated as vendored design tokens.
# New component styles (Svelte <style> + app.css) still get the full ruleset.
src/lib/styles/base/
src/lib/styles/legacy/
src/lib/styles/legacy.css
+32
View File
@@ -0,0 +1,32 @@
{
"extends": ["stylelint-config-standard"],
"overrides": [
{
"files": ["**/*.svelte"],
"customSyntax": "postcss-html"
},
{
"files": ["src/lib/styles/base/variables.css"],
"rules": {
"color-no-hex": null,
"color-named": null,
"function-disallowed-list": null
}
}
],
"rules": {
"color-no-hex": true,
"color-named": "never",
"selector-pseudo-class-no-unknown": [true, { "ignorePseudoClasses": ["global"] }],
"property-no-unknown": [true, { "ignoreProperties": ["/^--/"] }],
"function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "hwb"],
"selector-disallowed-list": ["/\\[data-theme/"],
"selector-class-pattern": null,
"custom-property-empty-line-before": null,
"declaration-empty-line-before": null,
"comment-empty-line-before": null,
"no-descending-specificity": null,
"value-keyword-case": null,
"property-no-vendor-prefix": null
}
}
+32
View File
@@ -0,0 +1,32 @@
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import prettier from 'eslint-config-prettier';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'package/']
}
);
+6261
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "oxicloud-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint .",
"format": "prettier --write .",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.6",
"@types/node": "^22.19.21",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"jsdom": "^25.0.1",
"postcss-html": "^1.7.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"stylelint": "^16.12.0",
"stylelint-config-standard": "^36.0.1",
"svelte": "^5.16.0",
"svelte-check": "^4.1.1",
"typescript": "^5.7.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.6",
"vitest": "^3.2.4"
}
}
+12
View File
@@ -0,0 +1,12 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en" data-color-scheme>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
<meta name="color-scheme" content="light dark" />
<!--
Anti-FOUC theme init — runs synchronously before first paint.
Ported from static/js/core/theme-init.js. Keeps the legacy
`oxicloud_theme` localStorage key and `data-color-scheme` attribute
so existing users keep their preference across the migration.
-->
<script>
(function () {
try {
var s = localStorage.getItem('oxicloud_theme');
var h = document.documentElement;
if (s === 'light' || s === 'dark') h.setAttribute('data-color-scheme', s);
else h.removeAttribute('data-color-scheme');
} catch (e) {
/* localStorage unavailable — fall back to OS preference */
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
/**
* Client startup hook. Runs once before the first route renders:
* - wires the API client's session-expired behaviour (clear store + redirect),
* - loads translations for the resolved locale.
*/
import { setSessionExpiredHandler } from '$lib/api/client';
import { initI18n } from '$lib/i18n/index.svelte';
import { session } from '$lib/stores/session.svelte';
export async function init(): Promise<void> {
setSessionExpiredHandler(() => {
session.reset();
if (typeof window !== 'undefined') {
window.location.href = '/login?source=session_expired';
}
});
await initI18n();
}
+130
View File
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createApiFetch } from './client';
const ORIGIN = 'https://cloud.example';
function jsonResponse(status: number, body: unknown = {}): Response {
return new Response(JSON.stringify(body), { status });
}
describe('createApiFetch — 401 refresh/retry parity', () => {
let onSessionExpired: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSessionExpired = vi.fn();
});
it('passes through a non-401 response untouched (no refresh)', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(200, { ok: true }));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('on 401 refreshes once then retries the original request', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { retried: true })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/files`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ retried: true });
expect(rawFetch).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({ method: 'POST' })
);
expect(rawFetch).toHaveBeenCalledTimes(3);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('fires session-expired and throws when refresh fails', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original
.mockResolvedValueOnce(jsonResponse(401)); // refresh fails
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
await expect(apiFetch(`${ORIGIN}/api/files`)).rejects.toThrow('Session expired');
expect(onSessionExpired).toHaveBeenCalledTimes(1);
expect(rawFetch).toHaveBeenCalledTimes(2); // original + refresh, NO retry
});
it('deduplicates concurrent 401s into a single refresh', async () => {
let refreshCalls = 0;
const rawFetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : (input as Request).url;
if (url.includes('/api/auth/refresh')) {
refreshCalls++;
await new Promise((r) => setTimeout(r, 10));
return jsonResponse(200);
}
// First hit per resource is a 401; retries (after refresh) succeed.
return jsonResponse(refreshCalls > 0 ? 200 : 401);
});
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const [a, b] = await Promise.all([
apiFetch(`${ORIGIN}/api/files`),
apiFetch(`${ORIGIN}/api/folders`)
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(refreshCalls).toBe(1); // single shared refresh
});
it('passes cross-origin 401s through without refreshing', async () => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch('https://third-party.example/api/thing');
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1); // no refresh attempt
expect(onSessionExpired).not.toHaveBeenCalled();
});
it.each([
'/api/auth/login',
'/api/auth/logout',
'/api/auth/refresh',
'/api/auth/register',
'/api/auth/setup',
'/api/auth/oidc/start',
'/api/auth/device/code',
'/api/s/sometoken'
])('bypasses refresh for auth primitive / public share: %s', async (path) => {
const rawFetch = vi.fn().mockResolvedValue(jsonResponse(401));
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}${path}`);
expect(res.status).toBe(401);
expect(rawFetch).toHaveBeenCalledTimes(1);
expect(onSessionExpired).not.toHaveBeenCalled();
});
it('retries user-data endpoints under /api/auth/ (e.g. me)', async () => {
const rawFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse(401)) // original /api/auth/me
.mockResolvedValueOnce(jsonResponse(200)) // refresh ok
.mockResolvedValueOnce(jsonResponse(200, { id: 'u1' })); // retry
const apiFetch = createApiFetch({ rawFetch, onSessionExpired, origin: ORIGIN });
const res = await apiFetch(`${ORIGIN}/api/auth/me`);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ id: 'u1' });
expect(rawFetch).toHaveBeenCalledTimes(3);
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Typed API client with transparent 401 → token-refresh → retry.
*
* Ported from static/js/core/fetchWrapper.js. Unlike 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>
View File
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "لم يعد رابط تسجيل الدخول صالحًا",
"expired_body": "ربما انتهت صلاحية الرابط أو تم استخدامه بالفعل. يمكننا إرسال رابط جديد لك — سيصل إلى صندوق الوارد خلال ثوانٍ.",
"resend_to": "أرسل رابطًا جديدًا إلى {{email}}",
"generic_unavailable": "لم يعد رابط تسجيل الدخول صالحًا. ربما تم استخدامه بالفعل أو انتهت صلاحيته. اطلب رابطًا جديدًا من صفحة تسجيل الدخول.",
"service_unavailable": "تسجيل الدخول عبر الرابط السحري غير مفعّل على هذا الخادم.",
"internal_error": "حدث خطأ أثناء تسجيل الدخول. يُرجى المحاولة مرة أخرى.",
"resend_failure": "حدث خطأ أثناء إرسال الرابط. يُرجى المحاولة مرة أخرى.",
"cross_browser_title": "هل تريد متابعة تسجيل الدخول على هذا الجهاز؟",
"cross_browser_body": "لقد فتحت رابط تسجيل الدخول في متصفح أو جهاز مختلف عن الجهاز الذي طلبته منه.",
"cross_browser_warning": "إذا كنت قد طلبت هذا الرابط، فمن الآمن المتابعة. إذا لم تطلبه، أغلق هذه الصفحة — النقر على «متابعة» سيُسجّل دخول شخص آخر إلى حسابك.",
"cross_browser_continue": "متابعة وتسجيل الدخول",
"resend_confirmation_title": "تحقق من صندوق الوارد",
"resend_confirmation_body": "إذا كان رابط تسجيل الدخول ينتمي إلى حساب نشط، فقد تم للتو إرسال رابط جديد. يُرجى التحقق من صندوق الوارد.",
"return_link": "العودة إلى OxiCloud"
},
"email": {
"invitation": {
"subject": "شارك {{inviter}} معك {{kind}} على OxiCloud",
"body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتحه بالنقر على الرابط أدناه:\n{{link}}\n\nيعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_hours}} ساعة.\nإذا لم تكن تتوقع هذه الدعوة، يمكنك تجاهل هذه الرسالة.\n\n— OxiCloud"
},
"login": {
"subject": "تسجيل الدخول إلى OxiCloud",
"body": "مرحبًا،\n\nاستخدم الرابط أدناه لتسجيل الدخول إلى OxiCloud. يعمل الرابط مرة واحدة وتنتهي صلاحيته خلال {{ttl_minutes}} دقيقة. افتحه على الجهاز نفسه الذي طلبت منه الرابط.\n\n{{link}}\n\nإذا لم تطلب رابط تسجيل الدخول هذا، يمكنك تجاهل هذه الرسالة — لا حاجة لأي إجراء إضافي.\n\n— OxiCloud"
},
"kind_file": "ملف",
"kind_folder": "مجلد",
"english_fallback_divider": "--- النسخة الإنجليزية أدناه ---"
}
},
"notification": {
"share": {
"subject": "شارك {{inviter}} معك {{kind}} على OxiCloud",
"body": "شارك {{inviter_full}} معك {{kind}} على OxiCloud.\n\nافتح OxiCloud لعرض مشاركتك الجديدة:\n{{login_link}}\n\nقد تكون لديك مشاركات جديدة أخرى من {{inviter}} — سجّل الدخول لرؤية جميع العناصر المشاركة معك.\n\n— OxiCloud\n\nأنت تتلقى هذه الرسالة لأن لديك حسابًا في OxiCloud وتفضيل إشعارات المشاركة مُفعّل. يمكنك تعطيله من ملفك الشخصي (راسلني عندما يشاركني شخص ما)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "نظام تخزين سحابي بسيط"
},
"nav": {
"files": "الملفات",
"shared": "مشاركاتي",
"recent": "الأخيرة",
"favorites": "المفضلة",
"photos": "الصور",
"music": "الموسيقى",
"trash": "سلة المهملات",
"sharedwithme": "مشتركة معي"
},
"photos": {
"empty_state": "لا توجد صور بعد",
"empty_hint": "ارفع صوراً أو مقاطع فيديو لعرضها هنا",
"items_selected": "محدد",
"view_daily": "يوم",
"view_monthly": "شهر",
"view_yearly": "سنة"
},
"music": {
"create_playlist": "إنشاء قائمة تشغيل",
"playlists": "قوائم التشغيل",
"no_playlists": "لا توجد قوائم تشغيل بعد",
"select_playlist": "اختر قائمة تشغيل",
"select_hint": "اختر قائمة تشغيل من الشريط الجانبي أو أنشئ واحدة جديدة",
"add_tracks": "إضافة مقاطع",
"no_tracks": "لا توجد مقاطع في هذه القائمة",
"unknown_artist": "فنان غير معروف",
"unknown_title": "غير معروف",
"confirm_delete": "هل تريد حذف هذه القائمة؟",
"playlist_name": "اسم القائمة",
"create": "إنشاء",
"delete": "حذف",
"share": "مشاركة",
"edit": "تعديل",
"play_all": "تشغيل الكل",
"shuffle": "عشوائي",
"repeat": "تكرار",
"repeat_one": "تكرار واحد",
"queue": "قائمة الانتظار",
"queue_empty": "قائمة الانتظار فارغة",
"not_playing": "لا يتم التشغيل",
"play": "تشغيل",
"pause": "إيقاف مؤقت",
"previous": "السابق",
"next": "التالي",
"volume": "مستوى الصوت",
"mute": "كتم",
"unmute": "إلغاء الكتم",
"title": "العنوان",
"artist": "الفنان",
"album": "الألبوم",
"tracks": "مقاطع",
"add": "إضافة",
"added": "تمت الإضافة!",
"added_to_playlist": "تمت إضافته إلى القائمة",
"add_to_playlist": "إضافة إلى القائمة",
"load_error": "خطأ في تحميل القوائم",
"add_error": "تعذر إضافة المقاطع",
"no_playlists_yet": "لا توجد قوائم بعد. أنشئ واحدة أولاً!",
"selected_files": "محدد:",
"error": "خطأ",
"search_audio": "البحث عن ملفات صوتية…",
"no_audio_files": "لم يتم العثور على ملفات صوتية",
"selected": "محدد",
"loading": "جارٍ التحميل…",
"search_error": "تعذر تحميل الملفات الصوتية",
"adding": "جارٍ الإضافة…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "البحث في الملفات...",
"new_folder": "مجلد جديد",
"upload": "رفع",
"upload_files": "رفع ملفات",
"upload_folder": "رفع مجلد",
"upload.uploading": "جارٍ الرفع...",
"upload.complete": "{count} / {total} تم رفعها",
"upload.files": "ملفات",
"rename": "إعادة التسمية",
"move": "نقل إلى...",
"move_to": "نقل إلى",
"delete": "حذف",
"download": "تحميل",
"view": "عرض",
"cancel": "إلغاء",
"confirm": "تأكيد",
"share": "مشاركة",
"favorite": "إضافة للمفضلة",
"unfavorite": "إزالة من المفضلة",
"copy": "نسخ",
"notify": "إشعار",
"send": "إرسال",
"clear_recent": "مسح الأخيرة",
"logout": "تسجيل الخروج",
"create": "إنشاء",
"search_btn": "بحث",
"close": "إغلاق",
"delete_permanently": "حذف نهائياً",
"empty_trash": "تفريغ سلة المهملات",
"open_parent_folder": "الانتقال إلى المجلد الأصلي",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "المظهر",
"about": "حول OxiCloud",
"about_description": "منصة تخزين سحابي مبنية بـ Rust و Clean Architecture. سريعة وآمنة وخاصة.",
"admin_panel": "لوحة الإدارة",
"profile": "ملفي الشخصي",
"role_user": "مستخدم",
"theme": {
"light": "فاتح",
"dark": "داكن",
"auto": "مثل النظام"
},
"manage_groups": "إدارة المجموعات"
},
"share": {
"dialogTitle": "رابط المشاركة",
"linkLabel": "رابط المشاركة:",
"copyLink": "نسخ",
"permissions": "الصلاحيات:",
"permissionRead": "قراءة",
"permissionWrite": "كتابة",
"permissionReshare": "إعادة مشاركة",
"password": "حماية بكلمة مرور:",
"generatePassword": "توليد",
"expiration": "تاريخ انتهاء الصلاحية:",
"update": "تحديث المشاركة",
"remove": "إزالة المشاركة",
"notifyTitle": "إرسال إشعار",
"notifyEmailLabel": "عنوان البريد الإلكتروني:",
"notifyMessageLabel": "رسالة (اختياري):",
"notifySend": "إرسال الإشعار",
"shareWithOthers": "مشاركة مع آخرين",
"sharePublicly": "مشاركة عامة",
"shareSettings": "إعدادات المشاركة",
"shareCopied": "تم نسخ الرابط إلى الحافظة",
"shareCreated": "تم إنشاء رابط المشاركة بنجاح",
"shareUpdated": "تم تحديث إعدادات المشاركة بنجاح",
"shareRemoved": "تمت إزالة المشاركة بنجاح",
"inviteByEmail": "دعوة عبر البريد الإلكتروني — ستُرسل الدعوة",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "رابط المشاركة",
"share_linkLabel": "رابط المشاركة:",
"share_copyLink": "نسخ",
"share_permissions": "الصلاحيات:",
"share_permissionRead": "قراءة",
"share_permissionWrite": "كتابة",
"share_permissionReshare": "إعادة مشاركة",
"share_password": "حماية بكلمة مرور:",
"share_generatePassword": "توليد",
"share_expiration": "تاريخ انتهاء الصلاحية:",
"share_update": "تحديث المشاركة",
"share_remove": "إزالة المشاركة",
"share_notifyTitle": "إرسال إشعار",
"share_notifyEmailLabel": "عنوان البريد الإلكتروني:",
"share_notifyMessageLabel": "رسالة (اختياري):",
"share_notifySend": "إرسال الإشعار",
"shared": {
"backToFiles": "العودة إلى الملفات",
"pageTitle": "الموارد المشتركة",
"pageDescription": "إدارة ملفاتك ومجلداتك المشتركة",
"filterType": "النوع:",
"filterAll": "الكل",
"filterFiles": "ملفات",
"filterFolders": "مجلدات",
"sortBy": "ترتيب حسب:",
"sortByName": "الاسم",
"sortByDate": "تاريخ المشاركة",
"sortByExpiration": "انتهاء الصلاحية",
"search": "بحث",
"colName": "الاسم",
"colType": "النوع",
"colDateShared": "تاريخ المشاركة",
"colExpiration": "انتهاء الصلاحية",
"colPermissions": "الصلاحيات",
"colPassword": "كلمة المرور",
"colActions": "الإجراءات",
"emptyStateTitle": "لا توجد موارد مشتركة بعد",
"emptyStateDesc": "عندما تشارك ملفات أو مجلدات، ستظهر هنا",
"goToFiles": "الذهاب إلى الملفات",
"typeFile": "ملف",
"typeFolder": "مجلد",
"noExpiration": "بدون انتهاء صلاحية",
"hasPassword": "نعم",
"noPassword": "لا",
"editShare": "تعديل المشاركة",
"notifyShare": "إشعار شخص ما",
"copyLink": "نسخ الرابط",
"removeShare": "إزالة المشاركة",
"linkCopied": "تم نسخ الرابط إلى الحافظة!",
"linkCopyFailed": "فشل نسخ الرابط",
"itemUpdated": "تم تحديث إعدادات المشاركة بنجاح",
"itemRemoved": "تمت إزالة المشاركة بنجاح",
"invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح",
"notificationSent": "تم إرسال الإشعار بنجاح",
"notificationFailed": "فشل إرسال الإشعار",
"shared_backToFiles": "العودة إلى الملفات",
"shared_pageTitle": "الموارد المشتركة",
"shared_pageDescription": "إدارة ملفاتك ومجلداتك المشتركة",
"shared_filterType": "النوع:",
"shared_filterAll": "الكل",
"shared_filterFiles": "ملفات",
"shared_filterFolders": "مجلدات",
"shared_sortBy": "ترتيب حسب:",
"shared_sortByName": "الاسم",
"shared_sortByDate": "تاريخ المشاركة",
"shared_sortByExpiration": "انتهاء الصلاحية",
"shared_search": "بحث",
"shared_colName": "الاسم",
"shared_colType": "النوع",
"shared_colDateShared": "تاريخ المشاركة",
"shared_colExpiration": "انتهاء الصلاحية",
"shared_colPermissions": "الصلاحيات",
"shared_colPassword": "كلمة المرور",
"shared_colActions": "الإجراءات",
"shared_emptyStateTitle": "لا توجد موارد مشتركة بعد",
"shared_emptyStateDesc": "عندما تشارك ملفات أو مجلدات، ستظهر هنا",
"shared_goToFiles": "الذهاب إلى الملفات",
"shared_typeFile": "ملف",
"shared_typeFolder": "مجلد",
"shared_noExpiration": "بدون انتهاء صلاحية",
"shared_hasPassword": "نعم",
"shared_noPassword": "لا",
"shared_editShare": "تعديل المشاركة",
"shared_notifyShare": "إشعار شخص ما",
"shared_copyLink": "نسخ الرابط",
"shared_removeShare": "إزالة المشاركة",
"shared_linkCopied": "تم نسخ الرابط إلى الحافظة!",
"shared_linkCopyFailed": "فشل نسخ الرابط",
"shared_itemUpdated": "تم تحديث إعدادات المشاركة بنجاح",
"shared_itemRemoved": "تمت إزالة المشاركة بنجاح",
"shared_invalidEmail": "يرجى إدخال عنوان بريد إلكتروني صالح",
"shared_notificationSent": "تم إرسال الإشعار بنجاح",
"shared_notificationFailed": "فشل إرسال الإشعار"
},
"files": {
"name": "الاسم",
"type": "النوع",
"size": "الحجم",
"modified": "تاريخ التعديل",
"no_files": "لا توجد ملفات في هذا المجلد",
"empty_hint": "ارفع ملفات أو أنشئ مجلدات للبدء",
"loading": "جارٍ تحميل الملفات…",
"view_grid": "عرض شبكي",
"view_list": "عرض قائمة",
"file_types": {
"document": "مستند",
"image": "صورة",
"video": "فيديو",
"audio": "صوت",
"pdf": "PDF",
"text": "نص",
"folder": "مجلد",
"spreadsheet": "جدول بيانات",
"presentation": "عرض تقديمي",
"archive": "أرشيف",
"installer": "مثبّت",
"code": "كود"
},
"owner": "المالك"
},
"dialogs": {
"rename_folder": "إعادة تسمية المجلد",
"rename_file": "إعادة تسمية الملف",
"new_name": "الاسم الجديد",
"new_folder_title": "مجلد جديد",
"folder_name": "اسم المجلد",
"folder_placeholder": "مجلدي",
"rename_title": "إعادة التسمية",
"move_file": "نقل الملف",
"move_folder": "نقل المجلد",
"select_destination": "اختر المجلد الوجهة:",
"select_this_folder": "اختيار هذا المجلد",
"go_to_parent": ".. (المجلد الأعلى)",
"no_subfolders": "لا توجد مجلدات فرعية",
"root": "الجذر",
"delete_confirmation": "هل أنت متأكد أنك تريد حذف",
"and_contents": "وجميع محتوياته",
"no_undo": "لا يمكن التراجع عن هذا الإجراء",
"confirm_title": "تأكيد الإجراء",
"confirm_delete": "نقل إلى سلة المهملات",
"confirm_delete_file": "هل أنت متأكد أنك تريد نقل الملف \"{{name}}\" إلى سلة المهملات؟",
"confirm_delete_folder": "هل أنت متأكد أنك تريد نقل المجلد \"{{name}}\" وجميع محتوياته إلى سلة المهملات؟",
"confirm_permanent_delete": "حذف نهائي",
"confirm_permanent_delete_msg": "هل أنت متأكد أنك تريد حذف هذا العنصر نهائياً؟ لا يمكن التراجع عن هذا الإجراء.",
"confirm_empty_trash": "تفريغ سلة المهملات",
"confirm_delete_share": "حذف رابط المشاركة",
"confirm_delete_share_msg": "هل أنت متأكد أنك تريد حذف رابط المشاركة هذا؟",
"share_file": "مشاركة الملف",
"share_folder": "مشاركة المجلد",
"existing_shares": "المشاركات الحالية",
"share_options": "خيارات المشاركة",
"password": "كلمة المرور",
"expiration": "انتهاء الصلاحية",
"permissions": "الصلاحيات",
"generated_link": "الرابط المُنشأ",
"notify": "إرسال إشعار",
"recipient": "المستلم",
"message": "الرسالة",
"move_to_home": "نقل إلى المجلد الرئيسي"
},
"dropzone": {
"drag_files": "اسحب الملفات هنا أو انقر للاختيار",
"drop_files": "أسقط الملفات للرفع"
},
"permissions": {
"read": "قراءة",
"write": "كتابة",
"reshare": "إعادة مشاركة"
},
"errors": {
"file_not_found": "الملف غير موجود",
"folder_not_found": "المجلد غير موجود",
"delete_error": "خطأ في الحذف",
"upload_error": "خطأ في رفع الملف",
"rename_error": "خطأ في إعادة التسمية",
"move_error": "خطأ في النقل",
"empty_name": "لا يمكن أن يكون الاسم فارغاً",
"name_exists": "ملف أو مجلد بهذا الاسم موجود بالفعل",
"generic_error": "حدث خطأ",
"group_name_invalid": "يجب أن يتطابق اسم المجموعة مع صيغة بادئة البريد الإلكتروني (حروف، أرقام، نقطة، شرطة، شرطة سفلية؛ 1–64 حرفًا).",
"group_cycle": "سينشئ هذا العضو مرجعًا دائريًا بين المجموعات.",
"group_depth_exceeded": "تتجاوز عمق التعشيش الحد الأقصى المسموح (8).",
"group_virtual_immutable": "مجموعة «Internal» تدار من قبل النظام ولا يمكن تعديلها.",
"group_not_found": "المجموعة غير موجودة.",
"group_name_taken": "توجد بالفعل مجموعة بهذا الاسم."
},
"breadcrumb": {
"home": "الرئيسية"
},
"trash": {
"empty_trash": "تفريغ سلة المهملات",
"empty_state": "سلة المهملات فارغة",
"original_location": "الموقع الأصلي",
"deleted_date": "تاريخ الحذف",
"remaining": "المتبقي",
"actions": "الإجراءات",
"restore": "استعادة",
"delete_permanently": "حذف نهائياً",
"empty_confirm": "هل أنت متأكد أنك تريد تفريغ سلة المهملات؟ سيتم حذف جميع العناصر نهائياً.",
"groupby": {
"remaining_days": "الأيام المتبقية",
"trashed_time": "وقت الحذف"
}
},
"daysRemaining": {
"expired": "منتهية الصلاحية",
"today": "اليوم",
"tomorrow": "غدًا",
"inDays": "{{count}} يوم"
},
"expiryChip": {
"never": "لا تنتهي الصلاحية",
"expired": "منتهية الصلاحية",
"today": "تنتهي الصلاحية اليوم",
"tomorrow": "تنتهي الصلاحية غدًا",
"inDays": "تنتهي الصلاحية خلال {{count}} يوم",
"onDate": "تنتهي الصلاحية في {{date}}"
},
"auth": {
"login_title": "تسجيل الدخول",
"username": "اسم المستخدم",
"username_placeholder": "أدخل اسم المستخدم",
"login_identifier": "اسم المستخدم أو البريد الإلكتروني",
"login_identifier_placeholder": "أدخل اسم المستخدم أو البريد الإلكتروني",
"password": "كلمة المرور",
"password_placeholder": "أدخل كلمة المرور",
"login_button": "تسجيل الدخول",
"no_account": "ليس لديك حساب؟",
"register": "إنشاء حساب",
"admin_setup": "أول مرة؟",
"setup": "إعداد المسؤول",
"register_title": "إنشاء حساب",
"email": "البريد الإلكتروني",
"email_placeholder": "أدخل بريدك الإلكتروني",
"confirm_password": "تأكيد كلمة المرور",
"confirm_password_placeholder": "أكد كلمة المرور",
"register_button": "إنشاء حساب",
"have_account": "لديك حساب بالفعل؟",
"login": "تسجيل الدخول",
"setup_title": "الإعداد الأولي",
"setup_step1": "المسؤول",
"setup_step2": "النظام",
"setup_step3": "مكتمل",
"admin_username": "اسم مستخدم المسؤول",
"admin_email": "بريد المسؤول الإلكتروني",
"admin_password": "كلمة مرور المسؤول",
"create_admin": "إنشاء حساب المسؤول",
"back_to_login": "تم الإعداد مسبقاً؟",
"admin_success": "تم إنشاء حساب المسؤول بنجاح! يمكنك الآن تسجيل الدخول.",
"account_success": "تم إنشاء الحساب بنجاح! يمكنك الآن تسجيل الدخول.",
"passwords_mismatch": "كلمات المرور غير متطابقة",
"admin_create_error": "خطأ في إنشاء حساب المسؤول",
"or": "أو",
"sso_login": "تسجيل الدخول عبر SSO",
"sso_login_provider": "تسجيل الدخول عبر {{provider}}",
"magicLinkHint": "ليس لديك كلمة مرور؟ أدخل بريدك الإلكتروني وسنرسل لك رابط تسجيل دخول لمرة واحدة.",
"magicLinkEmailLabel": "عنوان البريد الإلكتروني",
"magicLinkEmailPlaceholder": "you@example.com",
"magicLinkSubmit": "إرسال رابط تسجيل الدخول",
"magicLinkSent": "إذا كان هناك حساب لهذا البريد الإلكتروني، فسيتم إرسال رابط تسجيل الدخول. تحقق من صندوق الوارد.",
"magicLinkUnavailable": "تسجيل الدخول عبر البريد الإلكتروني غير متاح على هذا الخادم.",
"magicLinkNetworkError": "تعذر الوصول إلى الخادم: {{message}}",
"magicLinkToggle": "No password? Email me a sign-in link",
"passwordsMatch": "Passwords match",
"capsLock": "Caps Lock is on"
},
"storage": {
"title": "التخزين",
"calculating": "جارٍ الحساب...",
"used": "{{percentage}}% مستخدم ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "لا يمكن معاينة هذا النوع من الملفات.",
"download_file": "تحميل الملف",
"zoom_in": "تكبير",
"zoom_out": "تصغير",
"zoom_reset": "إعادة تعيين التكبير"
},
"language_selector": {
"title": "!مرحباً",
"subtitle": "اختر لغتك للمتابعة",
"continue": "متابعة",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "لا توجد مفضلات بعد",
"empty_hint": "ضع نجمة على الملفات أو المجلدات لإضافتها إلى المفضلة",
"add": "إضافة للمفضلة",
"remove": "إزالة من المفضلة",
"added_title": "أُضيف للمفضلة",
"added_msg": "أُضيف للمفضلة",
"removed_title": "أُزيل من المفضلة",
"removed_msg": "أُزيل من المفضلة"
},
"recent": {
"title": "الأخيرة",
"clear": "مسح الأخيرة",
"accessed": "تم الوصول",
"empty_state": "لا توجد ملفات حديثة",
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
"loadMore": "تحميل المزيد"
},
"notifications": {
"file_renamed": "تمت إعادة تسمية الملف",
"file_renamed_to": "تمت إعادة تسمية الملف إلى \"{{name}}\"",
"folder_renamed": "تمت إعادة تسمية المجلد",
"folder_renamed_to": "تمت إعادة تسمية المجلد إلى \"{{name}}\"",
"file_uploaded": "تم رفع الملف",
"file_deleted": "تم نقل الملف إلى سلة المهملات",
"folder_deleted": "تم نقل المجلد إلى سلة المهملات",
"item_deleted_permanently": "تم حذف العنصر نهائياً",
"trash_emptied": "تم تفريغ سلة المهملات بنجاح",
"title": "الإشعارات",
"empty": "لا توجد إشعارات",
"link_created": "تم إنشاء الرابط",
"share_success": "تم إنشاء رابط المشاركة بنجاح",
"upload_files_section_title": "التحميل غير متاح هنا",
"upload_files_section_body": "انتقل إلى قسم الملفات لتحميل الملفات"
},
"batch": {
"one_selected": "عنصر واحد محدد",
"n_selected": "{{count}} عناصر محددة",
"confirm_delete": "هل أنت متأكد أنك تريد نقل {{count}} عنصر إلى سلة المهملات؟",
"move_title": "نقل {{count}} عنصر",
"add_favorites": "إضافة للمفضلة",
"move_copy": "نقل أو نسخ"
},
"admin": {
"page_title": "لوحة الإدارة",
"back_to_app": "العودة إلى OxiCloud",
"loading": "جارٍ التحميل…",
"access_denied": "الوصول مرفوض",
"access_denied_desc": "صلاحيات المسؤول مطلوبة.",
"sign_in": "تسجيل الدخول",
"tab_dashboard": "لوحة المعلومات",
"tab_users": "المستخدمون",
"tab_oidc": "SSO / OIDC",
"total_users": "إجمالي المستخدمين",
"active_users": "المستخدمون النشطون",
"admins": "المسؤولون",
"version": "الإصدار",
"storage_overview": "نظرة عامة على التخزين",
"used": "مستخدم",
"total_quota": "الحصة الإجمالية",
"usage_pct": "نسبة الاستخدام",
"users_over_80": "مستخدمون >80%",
"users_over_quota": "مستخدمون تجاوزوا الحصة",
"system": "النظام",
"auth_label": "المصادقة",
"oidc_label": "OIDC",
"quotas_label": "الحصص",
"enabled": "مفعّل",
"disabled": "معطّل",
"active": "نشط",
"off": "متوقف",
"allow_registration": "السماح بالتسجيل العام",
"registration_warning": "التسجيل العام معطّل. فقط المسؤولون يمكنهم إنشاء مستخدمين.",
"user_management": "إدارة المستخدمين",
"create_user": "إنشاء مستخدم",
"col_user": "المستخدم",
"col_role": "الدور",
"col_auth": "المصادقة",
"col_status": "الحالة",
"col_storage": "التخزين",
"col_last_login": "آخر دخول",
"col_actions": "الإجراءات",
"loading_users": "جارٍ تحميل المستخدمين…",
"failed_load_users": "فشل التحميل",
"no_users_found": "لم يتم العثور على مستخدمين",
"showing_users": "عرض {{from}}-{{to}} من {{total}}",
"prev": "السابق",
"next": "التالي",
"inactive": "غير نشط",
"you_badge": "(أنت)",
"local": "محلي",
"never": "أبداً",
"just_now": "الآن",
"minutes_ago": "منذ {{n}} دقيقة",
"hours_ago": "منذ {{n}} ساعة",
"days_ago": "منذ {{n}} يوم",
"edit_quota_title": "تعديل الحصة",
"reset_password_title": "إعادة تعيين كلمة المرور",
"toggle_role_title": "تبديل الدور",
"deactivate_title": "تعطيل",
"activate_title": "تفعيل",
"delete_title": "حذف",
"sso_title": "تسجيل الدخول الموحد (OIDC / SSO)",
"enable_sso": "تفعيل مصادقة SSO",
"provider_name": "اسم الموفر",
"issuer_url": "عنوان المُصدر",
"issuer_url_hint": "عنوان مُصدر OpenID Connect",
"auto_discover": "اكتشاف تلقائي",
"discovering": "جارٍ الاكتشاف…",
"client_id": "معرّف العميل",
"client_secret": "سر العميل",
"client_secret_placeholder": "اتركه فارغاً للاحتفاظ بالقيمة",
"secret_configured": "سر العميل مُهيأ بالفعل",
"callback_url": "عنوان الاستدعاء",
"callback_url_hint": "(سجّل في IdP)",
"advanced_settings": "إعدادات متقدمة",
"scopes": "النطاقات",
"auto_provision": "إنشاء تلقائي عند أول دخول",
"admin_groups": "مجموعات المسؤولين",
"admin_groups_hint": "أسماء مجموعات OIDC مفصولة بفواصل",
"disable_password": "تعطيل الدخول بكلمة المرور (OIDC فقط)",
"password_warning": "سيمنع جميع عمليات الدخول بالمرور!",
"test_btn": "اختبار",
"save_btn": "حفظ",
"saving": "جارٍ الحفظ…",
"settings_saved": "تم الحفظ — OIDC الآن {{status}}",
"quota_modal_title": "تحديث حصة التخزين",
"quota_user_label": "المستخدم:",
"new_quota": "حصة جديدة",
"quota_unlimited_hint": "0 لغير محدود",
"cancel": "إلغاء",
"create_user_title": "إنشاء مستخدم جديد",
"username_label": "اسم المستخدم",
"username_placeholder": "اسم_المستخدم",
"username_hint": "3–32 حرفاً",
"password_label": "كلمة المرور",
"password_placeholder": "8 أحرف على الأقل",
"email_label": "البريد",
"email_optional": "(اختياري)",
"email_placeholder": "user@example.com (يُنشأ تلقائياً)",
"role_label": "الدور",
"role_user": "مستخدم",
"role_admin": "مسؤول",
"quota_label": "الحصة",
"creating": "جارٍ الإنشاء…",
"reset_pw_title": "إعادة تعيين كلمة المرور",
"new_password_label": "كلمة مرور جديدة",
"resetting": "جارٍ إعادة التعيين…",
"reset_btn": "إعادة تعيين",
"confirm_role_change": "تغيير الدور إلى {{role}}؟",
"confirm_deactivate": "هل أنت متأكد من التعطيل؟",
"confirm_activate": "هل أنت متأكد من التفعيل؟",
"confirm_delete_user": "حذف المستخدم \"{{name}}\"؟ لا يمكن التراجع!",
"confirm_action": "تأكيد الإجراء",
"confirm_yes": "تأكيد",
"confirm_no": "إلغاء",
"error_username_short": "الاسم 3 أحرف على الأقل",
"error_password_short": "كلمة المرور 8 أحرف على الأقل",
"error_generic": "فشل",
"error_network": "خطأ في الشبكة: {{message}}",
"error_create_user": "فشل إنشاء المستخدم",
"tab_storage": "التخزين",
"storage_title": "إعداد التخزين",
"storage_current_backend": "الواجهة الخلفية الحالية",
"storage_total_blobs": "إجمالي الكتل",
"storage_total_size": "الحجم الإجمالي",
"storage_dedup_ratio": "نسبة إزالة التكرار",
"storage_backend": "الواجهة الخلفية",
"storage_local": "محلي",
"storage_s3": "متوافق مع S3",
"storage_provider_preset": "إعداد مسبق للمزود",
"storage_preset_custom": "مخصص",
"storage_endpoint_url": "رابط نقطة النهاية",
"storage_endpoint_hint": "اتركه فارغاً لـ AWS S3",
"storage_bucket": "الحاوية",
"storage_region": "المنطقة",
"storage_access_key": "مفتاح الوصول",
"storage_secret_key": "المفتاح السري",
"storage_secret_configured": "تم إعداد المفتاح",
"storage_key_placeholder": "أدخل مفتاحاً جديداً",
"storage_path_style": "فرض أسلوب المسار",
"storage_path_style_hint": "مطلوب لـ MinIO وبعض الخدمات المتوافقة مع S3",
"storage_test_connection": "اختبار الاتصال",
"storage_test_success": "نجح الاتصال",
"storage_test_failure": "فشل الاتصال",
"storage_save": "حفظ الإعداد",
"storage_saved": "تم حفظ الإعداد",
"storage_migration": "ترحيل البيانات",
"storage_migration_coming_soon": "أدوات الترحيل قريباً",
"migration_status_label": "حالة الترحيل",
"migration_start": "بدء الترحيل",
"migration_pause": "إيقاف مؤقت",
"migration_resume": "استئناف",
"migration_verify": "التحقق",
"migration_complete": "إكمال",
"migration_started": "بدأ الترحيل",
"migration_paused_msg": "الترحيل متوقف مؤقتاً",
"migration_resumed_msg": "استُؤنف الترحيل",
"migration_completed_msg": "اكتمل الترحيل بنجاح",
"migration_verifying": "جارٍ التحقق...",
"migration_verify_passed": "اجتاز التحقق",
"migration_verify_failed": "فشل التحقق",
"migration_failed_blobs": "كتل فاشلة",
"testing": "جارٍ الاختبار...",
"smtp_disabled": "معطّل (المضيف غير مضبوط)",
"smtp_enabled": "مفعّل",
"smtp_enabled_label": "الحالة",
"smtp_intro": "يتم تكوين SMTP حصريًا عبر متغيرات البيئة (OXICLOUD_SMTP_*). تُقرأ القيم أدناه من الخادم قيد التشغيل — لتغييرها، عدّل البيئة وأعد تشغيل OxiCloud.",
"smtp_not_configured": "SMTP غير مكوَّن على هذا الخادم.",
"smtp_send_failed": "فشل الإرسال.",
"smtp_send_test": "إرسال بريد اختباري",
"smtp_sending": "جارٍ الإرسال…",
"smtp_sent": "تم إرسال البريد الاختباري.",
"smtp_server_code": "رد الخادم",
"smtp_test_intro": "يرسل رسالة تشخيصية محددة مسبقًا إلى المستلم أدناه ويُبلِّغ عن استجابة خادم SMTP لتتمكن من مطابقتها مع سجلات المرحّل الخاص بك.",
"smtp_test_missing_to": "أدخل عنوان المستلم.",
"smtp_test_title": "إرسال بريد اختباري",
"smtp_test_to": "عنوان المستلم",
"smtp_title": "البريد الصادر (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "الملف الشخصي",
"back_to_app": "العودة إلى OxiCloud",
"loading": "جارٍ التحميل…",
"not_authenticated": "غير مُصادق",
"not_authenticated_desc": "سجّل الدخول لعرض ملفك الشخصي.",
"sign_in": "تسجيل الدخول",
"role_admin": "مسؤول",
"role_user": "مستخدم",
"account_details": "تفاصيل الحساب",
"username": "اسم المستخدم",
"email": "البريد الإلكتروني",
"role": "الدور",
"last_login": "آخر دخول",
"storage": "التخزين",
"used": "مستخدم",
"quota": "الحصة",
"usage": "الاستخدام",
"unlimited": "غير محدود",
"app_passwords": "كلمات مرور التطبيقات",
"app_pw_desc": "أنشئ كلمات مرور لعملاء WebDAV و CalDAV و CardDAV. تُعرض كل كلمة مرور مرة واحدة فقط.",
"app_pw_label_placeholder": "التسمية (مثلاً Thunderbird، macOS)",
"generate": "إنشاء",
"generating": "جارٍ الإنشاء…",
"new_password_for": "كلمة مرور جديدة لـ",
"copy_warning": "انسخ كلمة المرور الآن. لن تتمكن من رؤيتها مرة أخرى.",
"copy_to_clipboard": "نسخ إلى الحافظة",
"col_label": "التسمية",
"col_created": "تاريخ الإنشاء",
"col_last_used": "آخر استخدام",
"col_status": "الحالة",
"active": "نشط",
"revoked": "ملغى",
"revoke_title": "إلغاء",
"no_app_passwords": "لا توجد كلمات مرور تطبيقات بعد.",
"client_sessions": "جلسات العميل",
"client_sessions_desc": "تُنشأ تلقائيًا عند اتصال عميل متوافق مع Nextcloud.",
"col_client": "العميل",
"never": "أبداً",
"just_now": "الآن",
"minutes_ago": "منذ {{n}} دقيقة",
"hours_ago": "منذ {{n}} ساعة",
"days_ago": "منذ {{n}} يوم",
"edit_profile": "تعديل الملف الشخصي",
"edit_oidc_managed": "لتغيير معلوماتك (الاسم، الاسم الأول، صورة الملف الشخصي، …)، يرجى تحديثها لدى مزود الهوية. ستظهر تغييراتك عند تسجيل الدخول التالي.",
"username_claim_hint": "2-64 حرفًا، أحرف / أرقام / نقطة / شرطة / شرطة سفلية. بمجرد الاختيار، لا يمكن تغيير اسم المستخدم (عملاء DAV/NextCloud يعتمدون عليه).",
"username_already_claimed": "اسم المستخدم محدد ولا يمكن تغييره (عملاء DAV/NextCloud يعتمدون عليه).",
"given_name": "الاسم الأول",
"family_name": "اسم العائلة",
"notify_on_share": "أرسل لي بريدًا إلكترونيًا عندما يشاركني شخص ما",
"notify_on_share_hint": "عند إلغاء التحديد، ستظل المشاركات تظهر في حسابك — لن تتلقى فقط بريدًا إلكترونيًا بشأنها.",
"save_profile": "حفظ التغييرات",
"profile_saved": "تم تحديث الملف الشخصي",
"profile_no_changes": "لا توجد تغييرات لحفظها.",
"profile_save_failed": "فشل الحفظ",
"username_taken_error": "اسم المستخدم هذا مستخدم بالفعل.",
"username_immutable_error": "اسم المستخدم الخاص بك محدد بالفعل ولا يمكن تغييره هنا. اتصل بالمسؤول إذا كنت بحاجة إلى إعادة التسمية.",
"change_password": "تغيير كلمة المرور",
"current_password": "كلمة المرور الحالية",
"new_password": "كلمة المرور الجديدة",
"min_8_chars": "8 أحرف على الأقل",
"confirm_password": "تأكيد كلمة المرور الجديدة",
"update_password": "تحديث كلمة المرور",
"updating": "جارٍ التحديث…",
"password_updated": "تم تحديث كلمة المرور بنجاح",
"passwords_no_match": "كلمتا المرور غير متطابقتين",
"password_too_short": "يجب أن تكون كلمة المرور 8 أحرف على الأقل",
"password_change_failed": "فشل تغيير كلمة المرور",
"error_network": "خطأ في الشبكة: {{message}}",
"error_label_required": "أدخل تسمية",
"error_create_pw": "فشل إنشاء كلمة المرور",
"confirm_revoke": "إلغاء كلمة المرور \"{{label}}\"؟ ستتوقف العملاء عن العمل.",
"error_revoke": "فشل الإلغاء",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "جارٍ الرفع...",
"files": "ملفات",
"complete": "{{count}} / {{total}} تم الرفع"
},
"storage_quota_exceeded": "تجاوز حصة التخزين",
"sharedwithme": {
"pageTitle": "مشترك معي",
"pageDescription": "الملفات والمجلدات التي شاركها معك مستخدمون آخرون",
"emptyStateTitle": "لم يُشارك معك أي شيء بعد",
"emptyStateDesc": "ستظهر هنا العناصر التي يشاركها معك مستخدمون آخرون",
"loadMore": "تحميل المزيد",
"sharedBy": "مشترك من قِبل",
"colName": "الاسم",
"colType": "النوع",
"colSharedBy": "مشترك من قِبل",
"colDate": "تاريخ المشاركة",
"colPermissions": "الصلاحيات"
},
"groupby": {
"none": "لا شيء",
"title": "التجميع حسب",
"owner": "المالك",
"shareDate": "تاريخ المشاركة",
"type": "النوع",
"type.folders": "المجلدات",
"accessedAt": "تاريخ الوصول",
"modifiedAt": "تاريخ التعديل",
"createdAt": "تاريخ الإنشاء",
"size": "الحجم",
"favoriteDate": "تاريخ المفضلة",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "جديد"
},
"dateBucket": {
"today": "اليوم",
"last7days": "آخر 7 أيام",
"last30days": "آخر 30 يومًا"
},
"groups": {
"title": "إدارة المجموعات",
"create_button": "إنشاء مجموعة",
"create_dialog_title": "مجموعة جديدة",
"edit_dialog_title": "إعادة تسمية المجموعة",
"name_label": "الاسم",
"name_placeholder": "engineering",
"description_label": "الوصف (اختياري)",
"members_section": "الأعضاء",
"add_member_placeholder": "إضافة مستخدم أو مجموعة…",
"no_members": "لا يوجد أعضاء بعد.",
"remove_member": "إزالة",
"delete_group": "حذف المجموعة",
"delete_confirm": "حذف المجموعة \"{name}\"؟ سيتم إلغاء الصلاحيات المرتبطة بهذه المجموعة.",
"empty_state": "لا توجد مجموعات بعد.",
"load_more": "تحميل المزيد",
"back_to_list": "رجوع",
"loading": "جارٍ التحميل…",
"virtual_badge": "النظام",
"member_count_zero": "لا يوجد أعضاء",
"member_count_one": "عضو واحد",
"member_count_other": "{count} أعضاء",
"delete_confirm_label": "اكتب اسم المجموعة للتأكيد:",
"delete_confirm_mismatch": "اكتب اسم المجموعة كما هو للتأكيد.",
"virtual_internal_name": "داخلي",
"members_loading": "جارٍ تحميل الأعضاء…",
"members_empty": "لا يوجد أعضاء",
"virtual_internal_explanation": "كل مستخدم داخلي على هذا الخادم"
},
"myshares": {
"copyLink": "نسخ الرابط",
"deleteLink": "حذف الرابط",
"notifyByEmail": "إشعار عبر البريد الإلكتروني",
"notifyFailed": "تعذّر إرسال الإشعار.",
"notifyGroupMembers": "إشعار أعضاء المجموعة",
"notifyRateLimited": "عدد كبير من الإشعارات لهذا المستلم — حاول لاحقًا.",
"removeAccess": "إزالة الوصول",
"resendInvitation": "إعادة إرسال بريد الدعوة"
},
"sort": {
"asc": "ascending",
"desc": "descending"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "Dieser Anmeldelink ist nicht mehr gültig",
"expired_body": "Der Link ist möglicherweise abgelaufen oder wurde bereits verwendet. Wir können Ihnen einen neuen senden — er wird in wenigen Sekunden in Ihrem Posteingang sein.",
"resend_to": "Neuen Link an {{email}} senden",
"generic_unavailable": "Dieser Anmeldelink ist nicht mehr gültig. Er wurde möglicherweise bereits verwendet oder ist abgelaufen. Fordern Sie auf der Anmeldeseite einen neuen Link an.",
"service_unavailable": "Die Magic-Link-Anmeldung ist auf diesem Server nicht aktiviert.",
"internal_error": "Bei der Anmeldung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"resend_failure": "Beim Senden des Links ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
"cross_browser_title": "Anmeldung auf diesem Gerät fortsetzen?",
"cross_browser_body": "Sie haben diesen Anmeldelink in einem anderen Browser oder Gerät geöffnet als dem, von dem aus Sie ihn angefordert haben.",
"cross_browser_warning": "Wenn Sie diesen Link angefordert haben, können Sie sicher fortfahren. Falls nicht, schließen Sie diese Seite — ein Klick auf Weiter würde jemand anderen in Ihrem Konto anmelden.",
"cross_browser_continue": "Fortfahren und anmelden",
"resend_confirmation_title": "Prüfen Sie Ihren Posteingang",
"resend_confirmation_body": "Falls der Anmeldelink zu einem aktiven Konto gehörte, wurde gerade ein neuer Link gesendet. Bitte prüfen Sie Ihren Posteingang.",
"return_link": "Zurück zu OxiCloud"
},
"email": {
"invitation": {
"subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt",
"body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie ihn, indem Sie auf den folgenden Link klicken:\n{{link}}\n\nDer Link kann nur einmal verwendet werden und läuft in {{ttl_hours}} Stunden ab.\nFalls Sie diese Einladung nicht erwartet haben, können Sie diese Nachricht ignorieren.\n\n— OxiCloud"
},
"login": {
"subject": "Anmeldung bei OxiCloud",
"body": "Hallo,\n\nVerwenden Sie den Link unten, um sich bei OxiCloud anzumelden. Der Link kann nur einmal verwendet werden und läuft in {{ttl_minutes}} Minuten ab. Öffnen Sie ihn auf demselben Gerät, von dem aus Sie ihn angefordert haben.\n\n{{link}}\n\nFalls Sie diesen Anmeldelink nicht angefordert haben, können Sie diese Nachricht ignorieren — es ist keine weitere Aktion erforderlich.\n\n— OxiCloud"
},
"kind_file": "Datei",
"kind_folder": "Ordner",
"english_fallback_divider": "--- Englische Version unten ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt",
"body": "{{inviter_full}} hat einen {{kind}} mit Ihnen auf OxiCloud geteilt.\n\nÖffnen Sie OxiCloud, um Ihre neue Freigabe zu sehen:\n{{login_link}}\n\nMöglicherweise gibt es weitere neue Freigaben von {{inviter}} — melden Sie sich an, um alle Ihre freigegebenen Elemente zu sehen.\n\n— OxiCloud\n\nSie erhalten diese Nachricht, weil Sie ein OxiCloud-Konto haben und die Benachrichtigung über Freigaben aktiviert ist. Sie können sie in Ihrem Profil deaktivieren (Per E-Mail benachrichtigen, wenn jemand mit mir teilt)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "Minimalistisches Cloud-Speichersystem"
},
"nav": {
"files": "Dateien",
"shared": "Freigaben",
"recent": "Zuletzt verwendet",
"favorites": "Favoriten",
"photos": "Fotos",
"music": "Musik",
"trash": "Papierkorb",
"sharedwithme": "Mit mir geteilt"
},
"photos": {
"empty_state": "Noch keine Fotos",
"empty_hint": "Laden Sie Bilder oder Videos hoch, um sie hier zu sehen",
"items_selected": "ausgewählt",
"view_daily": "Tag",
"view_monthly": "Monat",
"view_yearly": "Jahr"
},
"music": {
"create_playlist": "Playlist erstellen",
"playlists": "Playlists",
"no_playlists": "Noch keine Playlists",
"select_playlist": "Playlist auswählen",
"select_hint": "Wählen Sie eine Playlist aus der Seitenleiste oder erstellen Sie eine neue",
"add_tracks": "Titel hinzufügen",
"no_tracks": "Keine Titel in dieser Playlist",
"unknown_artist": "Unbekannter Künstler",
"unknown_title": "Unbekannt",
"confirm_delete": "Diese Playlist löschen?",
"playlist_name": "Playlist-Name",
"create": "Erstellen",
"delete": "Löschen",
"share": "Teilen",
"edit": "Bearbeiten",
"play_all": "Alle abspielen",
"shuffle": "Zufällig",
"repeat": "Wiederholen",
"repeat_one": "Einen wiederholen",
"queue": "Warteschlange",
"queue_empty": "Warteschlange ist leer",
"not_playing": "Nicht abspielend",
"play": "Abspielen",
"pause": "Pause",
"previous": "Zurück",
"next": "Weiter",
"volume": "Lautstärke",
"mute": "Stumm",
"unmute": "Ton ein",
"title": "Titel",
"artist": "Künstler",
"album": "Album",
"tracks": "Titel",
"add": "Hinzufügen",
"added": "Hinzugefügt!",
"added_to_playlist": "zur Playlist hinzugefügt",
"add_to_playlist": "Zur Playlist hinzufügen",
"load_error": "Fehler beim Laden der Playlists",
"add_error": "Tracks konnten nicht hinzugefügt werden",
"no_playlists_yet": "Noch keine Playlists. Erstellen Sie zuerst eine!",
"selected_files": "Ausgewählt:",
"error": "Fehler",
"search_audio": "Audiodateien suchen…",
"no_audio_files": "Keine Audiodateien gefunden",
"selected": "ausgewählt",
"loading": "Wird geladen…",
"search_error": "Audiodateien konnten nicht geladen werden",
"adding": "Wird hinzugefügt…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "Dateien suchen...",
"new_folder": "Neuer Ordner",
"upload": "Hochladen",
"upload_files": "Dateien hochladen",
"upload_folder": "Ordner hochladen",
"upload.uploading": "Wird hochgeladen...",
"upload.complete": "{count} / {total} hochgeladen",
"upload.files": "Dateien",
"rename": "Umbenennen",
"move": "Verschieben nach...",
"move_to": "Verschieben nach",
"delete": "Löschen",
"download": "Herunterladen",
"view": "Anzeigen",
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"share": "Teilen",
"favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen",
"copy": "Kopieren",
"notify": "Benachrichtigen",
"send": "Senden",
"clear_recent": "Zuletzt verwendete löschen",
"logout": "Abmelden",
"create": "Erstellen",
"search_btn": "Suchen",
"close": "Schließen",
"delete_permanently": "Endgültig löschen",
"empty_trash": "Papierkorb leeren",
"open_parent_folder": "Zum übergeordneten Ordner",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "Erscheinungsbild",
"about": "Über OxiCloud",
"about_description": "Cloud-Speicherplattform mit Rust und Clean Architecture. Schnell, sicher und privat.",
"admin_panel": "Admin-Panel",
"profile": "Mein Profil",
"role_user": "Benutzer",
"theme": {
"light": "Hell",
"dark": "Dunkel",
"auto": "Wie System"
},
"manage_groups": "Gruppen verwalten"
},
"share": {
"dialogTitle": "Link teilen",
"linkLabel": "Geteilter Link:",
"copyLink": "Kopieren",
"permissions": "Berechtigungen:",
"permissionRead": "Lesen",
"permissionWrite": "Schreiben",
"permissionReshare": "Weiterteilen",
"password": "Passwortschutz:",
"generatePassword": "Generieren",
"expiration": "Ablaufdatum:",
"update": "Freigabe aktualisieren",
"remove": "Freigabe entfernen",
"notifyTitle": "Benachrichtigung senden",
"notifyEmailLabel": "E-Mail-Adresse:",
"notifyMessageLabel": "Nachricht (optional):",
"notifySend": "Benachrichtigung senden",
"shareWithOthers": "Mit anderen teilen",
"sharePublicly": "Öffentlich teilen",
"shareSettings": "Freigabeeinstellungen",
"shareCopied": "Link in Zwischenablage kopiert",
"shareCreated": "Freigabelink erfolgreich erstellt",
"shareUpdated": "Freigabeeinstellungen aktualisiert",
"shareRemoved": "Freigabe erfolgreich entfernt",
"inviteByEmail": "Per E-Mail einladen — Einladung wird gesendet",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "Link teilen",
"share_linkLabel": "Geteilter Link:",
"share_copyLink": "Kopieren",
"share_permissions": "Berechtigungen:",
"share_permissionRead": "Lesen",
"share_permissionWrite": "Schreiben",
"share_permissionReshare": "Weiterteilen",
"share_password": "Passwortschutz:",
"share_generatePassword": "Generieren",
"share_expiration": "Ablaufdatum:",
"share_update": "Freigabe aktualisieren",
"share_remove": "Freigabe entfernen",
"share_notifyTitle": "Benachrichtigung senden",
"share_notifyEmailLabel": "E-Mail-Adresse:",
"share_notifyMessageLabel": "Nachricht (optional):",
"share_notifySend": "Benachrichtigung senden",
"shared": {
"backToFiles": "Zurück zu Dateien",
"pageTitle": "Geteilte Ressourcen",
"pageDescription": "Verwalten Sie Ihre geteilten Dateien und Ordner",
"filterType": "Typ:",
"filterAll": "Alle",
"filterFiles": "Dateien",
"filterFolders": "Ordner",
"sortBy": "Sortieren nach:",
"sortByName": "Name",
"sortByDate": "Freigabedatum",
"sortByExpiration": "Ablaufdatum",
"search": "Suchen",
"colName": "Name",
"colType": "Typ",
"colDateShared": "Freigabedatum",
"colExpiration": "Ablaufdatum",
"colPermissions": "Berechtigungen",
"colPassword": "Passwort",
"colActions": "Aktionen",
"emptyStateTitle": "Noch keine geteilten Ressourcen",
"emptyStateDesc": "Wenn Sie Dateien oder Ordner teilen, werden sie hier angezeigt",
"goToFiles": "Zu Dateien gehen",
"typeFile": "Datei",
"typeFolder": "Ordner",
"noExpiration": "Kein Ablaufdatum",
"hasPassword": "Ja",
"noPassword": "Nein",
"editShare": "Freigabe bearbeiten",
"notifyShare": "Jemanden benachrichtigen",
"copyLink": "Link kopieren",
"removeShare": "Freigabe entfernen",
"linkCopied": "Link in Zwischenablage kopiert!",
"linkCopyFailed": "Link konnte nicht kopiert werden",
"itemUpdated": "Freigabeeinstellungen aktualisiert",
"itemRemoved": "Freigabe erfolgreich entfernt",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"notificationSent": "Benachrichtigung erfolgreich gesendet",
"notificationFailed": "Benachrichtigung konnte nicht gesendet werden",
"shared_backToFiles": "Zurück zu Dateien",
"shared_pageTitle": "Geteilte Ressourcen",
"shared_pageDescription": "Verwalten Sie Ihre geteilten Dateien und Ordner",
"shared_filterType": "Typ:",
"shared_filterAll": "Alle",
"shared_filterFiles": "Dateien",
"shared_filterFolders": "Ordner",
"shared_sortBy": "Sortieren nach:",
"shared_sortByName": "Name",
"shared_sortByDate": "Freigabedatum",
"shared_sortByExpiration": "Ablaufdatum",
"shared_search": "Suchen",
"shared_colName": "Name",
"shared_colType": "Typ",
"shared_colDateShared": "Freigabedatum",
"shared_colExpiration": "Ablaufdatum",
"shared_colPermissions": "Berechtigungen",
"shared_colPassword": "Passwort",
"shared_colActions": "Aktionen",
"shared_emptyStateTitle": "Noch keine geteilten Ressourcen",
"shared_emptyStateDesc": "Wenn Sie Dateien oder Ordner teilen, werden sie hier angezeigt",
"shared_goToFiles": "Zu Dateien gehen",
"shared_typeFile": "Datei",
"shared_typeFolder": "Ordner",
"shared_noExpiration": "Kein Ablaufdatum",
"shared_hasPassword": "Ja",
"shared_noPassword": "Nein",
"shared_editShare": "Freigabe bearbeiten",
"shared_notifyShare": "Jemanden benachrichtigen",
"shared_copyLink": "Link kopieren",
"shared_removeShare": "Freigabe entfernen",
"shared_linkCopied": "Link in Zwischenablage kopiert!",
"shared_linkCopyFailed": "Link konnte nicht kopiert werden",
"shared_itemUpdated": "Freigabeeinstellungen aktualisiert",
"shared_itemRemoved": "Freigabe erfolgreich entfernt",
"shared_invalidEmail": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"shared_notificationSent": "Benachrichtigung erfolgreich gesendet",
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
},
"files": {
"name": "Name",
"type": "Typ",
"size": "Größe",
"modified": "Geändert",
"no_files": "Keine Dateien in diesem Ordner",
"empty_hint": "Laden Sie Dateien hoch oder erstellen Sie Ordner, um loszulegen",
"loading": "Dateien werden geladen…",
"view_grid": "Rasteransicht",
"view_list": "Listenansicht",
"file_types": {
"document": "Dokument",
"image": "Bild",
"video": "Video",
"audio": "Audio",
"pdf": "PDF",
"text": "Text",
"folder": "Ordner",
"spreadsheet": "Tabelle",
"presentation": "Präsentation",
"archive": "Archiv",
"installer": "Installationsdatei",
"code": "Code"
},
"owner": "Eigentümer"
},
"dialogs": {
"rename_folder": "Ordner umbenennen",
"rename_file": "Datei umbenennen",
"new_name": "Neuer Name",
"new_folder_title": "Neuer Ordner",
"folder_name": "Ordnername",
"folder_placeholder": "Mein Ordner",
"rename_title": "Umbenennen",
"move_file": "Datei verschieben",
"move_folder": "Ordner verschieben",
"select_destination": "Zielordner auswählen:",
"root": "Stammverzeichnis",
"delete_confirmation": "Sind Sie sicher, dass Sie löschen möchten",
"and_contents": "und den gesamten Inhalt",
"no_undo": "Diese Aktion kann nicht rückgängig gemacht werden",
"confirm_title": "Aktion bestätigen",
"confirm_delete": "In Papierkorb verschieben",
"confirm_delete_file": "Sind Sie sicher, dass Sie die Datei \"{{name}}\" in den Papierkorb verschieben möchten?",
"confirm_delete_folder": "Sind Sie sicher, dass Sie den Ordner \"{{name}}\" und seinen gesamten Inhalt in den Papierkorb verschieben möchten?",
"confirm_permanent_delete": "Endgültig löschen",
"confirm_permanent_delete_msg": "Sind Sie sicher, dass Sie dieses Element endgültig löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"confirm_empty_trash": "Papierkorb leeren",
"confirm_delete_share": "Freigabelink löschen",
"confirm_delete_share_msg": "Sind Sie sicher, dass Sie diesen Freigabelink löschen möchten?",
"share_file": "Datei teilen",
"share_folder": "Ordner teilen",
"existing_shares": "Bestehende Freigaben",
"share_options": "Freigabeoptionen",
"password": "Passwort",
"expiration": "Ablaufdatum",
"permissions": "Berechtigungen",
"generated_link": "Generierter Link",
"notify": "Benachrichtigung senden",
"recipient": "Empfänger",
"message": "Nachricht",
"go_to_parent": ".. (parent folder)",
"no_subfolders": "No subfolders",
"select_this_folder": "Select this folder",
"move_to_home": "In den Home-Ordner verschieben"
},
"dropzone": {
"drag_files": "Dateien hierher ziehen oder klicken zum Auswählen",
"drop_files": "Dateien zum Hochladen ablegen"
},
"permissions": {
"read": "Lesen",
"write": "Schreiben",
"reshare": "Weiterteilen"
},
"errors": {
"file_not_found": "Datei nicht gefunden",
"folder_not_found": "Ordner nicht gefunden",
"delete_error": "Fehler beim Löschen",
"upload_error": "Fehler beim Hochladen",
"rename_error": "Fehler beim Umbenennen",
"move_error": "Fehler beim Verschieben",
"empty_name": "Der Name darf nicht leer sein",
"name_exists": "Eine Datei oder ein Ordner mit diesem Namen existiert bereits",
"generic_error": "Ein Fehler ist aufgetreten",
"group_name_invalid": "Der Gruppenname muss dem E-Mail-Präfix-Format entsprechen (Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich; 1–64 Zeichen).",
"group_cycle": "Dieses Mitglied würde einen Gruppen-Zirkelbezug erzeugen.",
"group_depth_exceeded": "Die Verschachtelungstiefe überschreitet das zulässige Maximum (8).",
"group_virtual_immutable": "Die Gruppe „Internal“ wird vom System verwaltet und kann nicht geändert werden.",
"group_not_found": "Gruppe nicht gefunden.",
"group_name_taken": "Eine Gruppe mit diesem Namen existiert bereits."
},
"breadcrumb": {
"home": "Startseite"
},
"trash": {
"empty_trash": "Papierkorb leeren",
"empty_state": "Der Papierkorb ist leer",
"original_location": "Ursprünglicher Speicherort",
"deleted_date": "Löschdatum",
"remaining": "Verbleibend",
"actions": "Aktionen",
"restore": "Wiederherstellen",
"delete_permanently": "Endgültig löschen",
"empty_confirm": "Sind Sie sicher, dass Sie den Papierkorb leeren möchten? Alle Elemente werden endgültig gelöscht.",
"groupby": {
"remaining_days": "Verbleibende Tage",
"trashed_time": "Löschzeit"
}
},
"daysRemaining": {
"expired": "Abgelaufen",
"today": "Heute",
"tomorrow": "Morgen",
"inDays": "{{count}} Tage"
},
"expiryChip": {
"never": "Läuft nie ab",
"expired": "Abgelaufen",
"today": "Läuft heute ab",
"tomorrow": "Läuft morgen ab",
"inDays": "Läuft in {{count}} Tagen ab",
"onDate": "Läuft am {{date}} ab"
},
"auth": {
"login_title": "Anmelden",
"username": "Benutzername",
"username_placeholder": "Geben Sie Ihren Benutzernamen ein",
"login_identifier": "Benutzername oder E-Mail",
"login_identifier_placeholder": "Geben Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse ein",
"password": "Passwort",
"password_placeholder": "Geben Sie Ihr Passwort ein",
"login_button": "Anmelden",
"no_account": "Kein Konto?",
"register": "Registrieren",
"admin_setup": "Erstmalig?",
"setup": "Administrator einrichten",
"register_title": "Konto erstellen",
"email": "E-Mail",
"email_placeholder": "Geben Sie Ihre E-Mail ein",
"confirm_password": "Passwort bestätigen",
"confirm_password_placeholder": "Bestätigen Sie Ihr Passwort",
"register_button": "Konto erstellen",
"have_account": "Bereits ein Konto?",
"login": "Anmelden",
"setup_title": "Ersteinrichtung",
"setup_step1": "Admin",
"setup_step2": "System",
"setup_step3": "Abgeschlossen",
"admin_username": "Admin-Benutzername",
"admin_email": "Admin-E-Mail",
"admin_password": "Admin-Passwort",
"create_admin": "Administrator erstellen",
"back_to_login": "Bereits eingerichtet?",
"admin_success": "Administratorkonto erfolgreich erstellt! Sie können sich jetzt anmelden.",
"account_success": "Konto erfolgreich erstellt! Sie können sich jetzt anmelden.",
"passwords_mismatch": "Die Passwörter stimmen nicht überein",
"admin_create_error": "Fehler beim Erstellen des Administratorkontos",
"or": "oder",
"sso_login": "Mit SSO anmelden",
"sso_login_provider": "Mit {{provider}} anmelden",
"magicLinkHint": "Kein Passwort? Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen einmaligen Anmeldelink.",
"magicLinkEmailLabel": "E-Mail-Adresse",
"magicLinkEmailPlaceholder": "sie@beispiel.de",
"magicLinkSubmit": "Anmeldelink senden",
"magicLinkSent": "Wenn für diese E-Mail-Adresse ein Konto besteht, wurde ein Anmeldelink gesendet. Überprüfen Sie Ihren Posteingang.",
"magicLinkUnavailable": "Die Anmeldung per E-Mail ist auf diesem Server nicht verfügbar.",
"magicLinkNetworkError": "Server nicht erreichbar: {{message}}",
"magicLinkToggle": "Kein Passwort? Anmeldelink per E-Mail",
"passwordsMatch": "Passwörter stimmen überein",
"capsLock": "Feststelltaste aktiv"
},
"storage": {
"title": "Speicher",
"calculating": "Berechnung...",
"used": "{{percentage}}% verwendet ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "Dieser Dateityp kann nicht in der Vorschau angezeigt werden.",
"download_file": "Datei herunterladen",
"zoom_in": "Vergrößern",
"zoom_out": "Verkleinern",
"zoom_reset": "Zoom zurücksetzen"
},
"language_selector": {
"title": "Willkommen!",
"subtitle": "Wählen Sie Ihre Sprache, um fortzufahren",
"continue": "Weiter",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "Noch keine Favoriten",
"empty_hint": "Markieren Sie Dateien oder Ordner mit einem Stern, um sie zu Ihren Favoriten hinzuzufügen",
"add": "Zu Favoriten hinzufügen",
"remove": "Aus Favoriten entfernen",
"added_title": "Zu Favoriten hinzugefügt",
"added_msg": "zu Favoriten hinzugefügt",
"removed_title": "Aus Favoriten entfernt",
"removed_msg": "aus Favoriten entfernt"
},
"recent": {
"title": "Zuletzt verwendet",
"clear": "Zuletzt verwendete löschen",
"accessed": "Zugegriffen",
"empty_state": "Keine zuletzt verwendeten Dateien",
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt",
"loadMore": "Mehr laden"
},
"notifications": {
"file_renamed": "Datei umbenannt",
"file_renamed_to": "Datei umbenannt in \"{{name}}\"",
"folder_renamed": "Ordner umbenannt",
"folder_renamed_to": "Ordner umbenannt in \"{{name}}\"",
"file_uploaded": "Datei hochgeladen",
"file_deleted": "Datei in Papierkorb verschoben",
"folder_deleted": "Ordner in Papierkorb verschoben",
"item_deleted_permanently": "Element endgültig gelöscht",
"trash_emptied": "Papierkorb erfolgreich geleert",
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen",
"link_created": "Link erstellt",
"share_success": "Freigabelink erfolgreich erstellt",
"upload_files_section_title": "Upload hier nicht verfügbar",
"upload_files_section_body": "Wechseln Sie zum Abschnitt Dateien, um Dateien hochzuladen"
},
"batch": {
"one_selected": "1 Element ausgewählt",
"n_selected": "{{count}} Elemente ausgewählt",
"confirm_delete": "Möchten Sie wirklich {{count}} Elemente in den Papierkorb verschieben?",
"move_title": "{{count}} Element(e) verschieben",
"add_favorites": "Zu Favoriten hinzufügen",
"move_copy": "Verschieben oder kopieren"
},
"admin": {
"page_title": "Admin-Panel",
"back_to_app": "Zurück zu OxiCloud",
"loading": "Laden…",
"access_denied": "Zugriff verweigert",
"access_denied_desc": "Administratorrechte erforderlich.",
"sign_in": "Anmelden",
"tab_dashboard": "Dashboard",
"tab_users": "Benutzer",
"tab_oidc": "SSO / OIDC",
"total_users": "Benutzer gesamt",
"active_users": "Aktive Benutzer",
"admins": "Admins",
"version": "Version",
"storage_overview": "Speicherübersicht",
"used": "Verwendet",
"total_quota": "Gesamtkontingent",
"usage_pct": "Nutzung %",
"users_over_80": "Benutzer >80% Kontingent",
"users_over_quota": "Benutzer über Kontingent",
"system": "System",
"auth_label": "Auth",
"oidc_label": "OIDC",
"quotas_label": "Kontingente",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"active": "Aktiv",
"off": "Aus",
"allow_registration": "Öffentliche Selbstregistrierung erlauben",
"registration_warning": "Öffentliche Registrierung ist deaktiviert. Nur Admins können neue Benutzer erstellen.",
"user_management": "Benutzerverwaltung",
"create_user": "Benutzer erstellen",
"col_user": "Benutzer",
"col_role": "Rolle",
"col_auth": "Auth",
"col_status": "Status",
"col_storage": "Speicher",
"col_last_login": "Letzter Login",
"col_actions": "Aktionen",
"loading_users": "Benutzer werden geladen…",
"failed_load_users": "Laden fehlgeschlagen",
"no_users_found": "Keine Benutzer gefunden",
"showing_users": "Zeige {{from}}-{{to}} von {{total}}",
"prev": "Zurück",
"next": "Weiter",
"inactive": "Inaktiv",
"you_badge": "(du)",
"local": "Lokal",
"never": "Nie",
"just_now": "Gerade eben",
"minutes_ago": "vor {{n}}Min",
"hours_ago": "vor {{n}}Std",
"days_ago": "vor {{n}}T",
"edit_quota_title": "Kontingent bearbeiten",
"reset_password_title": "Passwort zurücksetzen",
"toggle_role_title": "Rolle wechseln",
"deactivate_title": "Deaktivieren",
"activate_title": "Aktivieren",
"delete_title": "Löschen",
"sso_title": "Single Sign-On (OIDC / SSO)",
"enable_sso": "SSO-Authentifizierung aktivieren",
"provider_name": "Anbietername",
"issuer_url": "Aussteller-URL",
"issuer_url_hint": "OpenID Connect Aussteller-URL Ihres Identitätsanbieters",
"auto_discover": "Auto-Erkennung",
"discovering": "Erkennung…",
"client_id": "Client-ID",
"client_secret": "Client-Secret",
"client_secret_placeholder": "Leer lassen für aktuellen Wert",
"secret_configured": "Ein Client-Secret ist bereits konfiguriert",
"callback_url": "Callback-URL",
"callback_url_hint": "(bei IdP registrieren)",
"advanced_settings": "Erweiterte Einstellungen",
"scopes": "Scopes",
"auto_provision": "Benutzer bei erstem Login automatisch anlegen",
"admin_groups": "Admin-Gruppen",
"admin_groups_hint": "Kommagetrennte OIDC-Gruppennamen für Admin-Rolle",
"disable_password": "Passwort-Login deaktivieren (nur OIDC)",
"password_warning": "Dies verhindert ALLE passwortbasierten Anmeldungen!",
"test_btn": "Testen",
"save_btn": "Speichern",
"saving": "Speichern…",
"settings_saved": "Einstellungen gespeichert — OIDC ist jetzt {{status}}",
"quota_modal_title": "Speicherkontingent aktualisieren",
"quota_user_label": "Benutzer:",
"new_quota": "Neues Kontingent",
"quota_unlimited_hint": "0 für unbegrenzt",
"cancel": "Abbrechen",
"create_user_title": "Neuen Benutzer erstellen",
"username_label": "Benutzername",
"username_placeholder": "maxmuster",
"username_hint": "3–32 Zeichen",
"password_label": "Passwort",
"password_placeholder": "Min. 8 Zeichen",
"email_label": "E-Mail",
"email_optional": "(optional)",
"email_placeholder": "benutzer@beispiel.de (automatisch wenn leer)",
"role_label": "Rolle",
"role_user": "Benutzer",
"role_admin": "Admin",
"quota_label": "Kontingent",
"creating": "Erstellen…",
"reset_pw_title": "Passwort zurücksetzen",
"new_password_label": "Neues Passwort",
"resetting": "Zurücksetzen…",
"reset_btn": "Zurücksetzen",
"confirm_role_change": "Rolle zu {{role}} ändern?",
"confirm_deactivate": "Diesen Benutzer wirklich deaktivieren?",
"confirm_activate": "Diesen Benutzer wirklich aktivieren?",
"confirm_delete_user": "Benutzer \"{{name}}\" LÖSCHEN? Kann nicht rückgängig gemacht werden!",
"confirm_action": "Aktion bestätigen",
"confirm_yes": "Bestätigen",
"confirm_no": "Abbrechen",
"error_username_short": "Benutzername muss mindestens 3 Zeichen haben",
"error_password_short": "Passwort muss mindestens 8 Zeichen haben",
"error_generic": "Fehlgeschlagen",
"error_network": "Netzwerkfehler: {{message}}",
"error_create_user": "Benutzer erstellen fehlgeschlagen",
"tab_storage": "Speicher",
"storage_title": "Speicherkonfiguration",
"storage_current_backend": "Aktuelles Backend",
"storage_total_blobs": "Gesamt-Blobs",
"storage_total_size": "Gesamtgröße",
"storage_dedup_ratio": "Deduplizierungsrate",
"storage_backend": "Backend",
"storage_local": "Lokal",
"storage_s3": "S3-kompatibel",
"storage_provider_preset": "Anbieter-Voreinstellung",
"storage_preset_custom": "Benutzerdefiniert",
"storage_endpoint_url": "Endpunkt-URL",
"storage_endpoint_hint": "Leer lassen für AWS S3",
"storage_bucket": "Bucket",
"storage_region": "Region",
"storage_access_key": "Zugriffsschlüssel",
"storage_secret_key": "Geheimschlüssel",
"storage_secret_configured": "Schlüssel konfiguriert",
"storage_key_placeholder": "Neuen Schlüssel eingeben",
"storage_path_style": "Pfadstil erzwingen",
"storage_path_style_hint": "Erforderlich für MinIO und einige S3-kompatible Dienste",
"storage_test_connection": "Verbindung testen",
"storage_test_success": "Verbindung erfolgreich",
"storage_test_failure": "Verbindung fehlgeschlagen",
"storage_save": "Konfiguration speichern",
"storage_saved": "Konfiguration gespeichert",
"storage_migration": "Datenmigration",
"storage_migration_coming_soon": "Migrationstools demnächst verfügbar",
"migration_status_label": "Migrationsstatus",
"migration_start": "Migration starten",
"migration_pause": "Pausieren",
"migration_resume": "Fortsetzen",
"migration_verify": "Verifizieren",
"migration_complete": "Abschließen",
"migration_started": "Migration gestartet",
"migration_paused_msg": "Migration pausiert",
"migration_resumed_msg": "Migration fortgesetzt",
"migration_completed_msg": "Migration erfolgreich abgeschlossen",
"migration_verifying": "Wird verifiziert...",
"migration_verify_passed": "Verifizierung erfolgreich",
"migration_verify_failed": "Verifizierung fehlgeschlagen",
"migration_failed_blobs": "Fehlgeschlagene Blobs",
"testing": "Wird getestet...",
"smtp_disabled": "Deaktiviert (Host nicht gesetzt)",
"smtp_enabled": "Aktiviert",
"smtp_enabled_label": "Status",
"smtp_intro": "SMTP wird ausschließlich über Umgebungsvariablen (OXICLOUD_SMTP_*) konfiguriert. Die folgenden Werte werden aus dem laufenden Server gelesen — zum Ändern bearbeiten Sie die Umgebung und starten OxiCloud neu.",
"smtp_not_configured": "SMTP ist auf diesem Server nicht konfiguriert.",
"smtp_send_failed": "Senden fehlgeschlagen.",
"smtp_send_test": "Test-E-Mail senden",
"smtp_sending": "Senden …",
"smtp_sent": "Test-E-Mail gesendet.",
"smtp_server_code": "Server antwortete",
"smtp_test_intro": "Sendet eine fest einprogrammierte Diagnosenachricht an den unten angegebenen Empfänger und meldet die Antwort des SMTP-Servers, sodass Sie sie mit Ihren Relay-Protokollen abgleichen können.",
"smtp_test_missing_to": "Geben Sie eine Empfängeradresse ein.",
"smtp_test_title": "Test-E-Mail senden",
"smtp_test_to": "Empfängeradresse",
"smtp_title": "Ausgehende E-Mail (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "Profil",
"back_to_app": "Zurück zu OxiCloud",
"loading": "Laden…",
"not_authenticated": "Nicht authentifiziert",
"not_authenticated_desc": "Bitte melden Sie sich an, um Ihr Profil anzuzeigen.",
"sign_in": "Anmelden",
"role_admin": "Administrator",
"role_user": "Benutzer",
"account_details": "Kontodetails",
"username": "Benutzername",
"email": "E-Mail",
"role": "Rolle",
"last_login": "Letzter Login",
"storage": "Speicher",
"used": "Verwendet",
"quota": "Kontingent",
"usage": "Nutzung",
"unlimited": "Unbegrenzt",
"app_passwords": "App-Passwörter",
"app_pw_desc": "Passwörter für WebDAV-, CalDAV- und CardDAV-Clients generieren. Jedes Passwort wird nur einmal angezeigt.",
"app_pw_label_placeholder": "Bezeichnung (z.B. Thunderbird, macOS)",
"generate": "Generieren",
"generating": "Generieren…",
"new_password_for": "Neues Passwort für",
"copy_warning": "Kopieren Sie dieses Passwort jetzt. Sie können es nicht erneut anzeigen.",
"copy_to_clipboard": "In Zwischenablage kopieren",
"col_label": "Bezeichnung",
"col_created": "Erstellt",
"col_last_used": "Zuletzt verwendet",
"col_status": "Status",
"active": "Aktiv",
"revoked": "Widerrufen",
"revoke_title": "Widerrufen",
"no_app_passwords": "Noch keine App-Passwörter.",
"client_sessions": "Client-Sitzungen",
"client_sessions_desc": "Automatisch generiert beim Verbinden eines Nextcloud-kompatiblen Clients.",
"col_client": "Client",
"never": "Nie",
"just_now": "Gerade eben",
"minutes_ago": "vor {{n}} Min",
"hours_ago": "vor {{n}} Std",
"days_ago": "vor {{n}} Tagen",
"edit_profile": "Profil bearbeiten",
"edit_oidc_managed": "Um Ihre Informationen (Name, Vorname, Profilbild, …) zu ändern, aktualisieren Sie sie bitte bei Ihrem Identity-Provider. Ihre Änderungen erscheinen bei der nächsten Anmeldung.",
"username_claim_hint": "2–64 Zeichen, Buchstaben / Ziffern / Punkt / Bindestrich / Unterstrich. Nach der Wahl kann der Benutzername nicht mehr geändert werden (DAV/NextCloud-Clients hängen davon ab).",
"username_already_claimed": "Benutzername ist gesetzt und kann nicht geändert werden (DAV/NextCloud-Clients hängen davon ab).",
"given_name": "Vorname",
"family_name": "Nachname",
"notify_on_share": "Mich per E-Mail benachrichtigen, wenn jemand mit mir teilt",
"notify_on_share_hint": "Wenn deaktiviert, werden Freigaben weiterhin in deinem Konto angezeigt — du erhältst nur keine E-Mail dazu.",
"save_profile": "Änderungen speichern",
"profile_saved": "Profil aktualisiert",
"profile_no_changes": "Keine Änderungen zu speichern.",
"profile_save_failed": "Speichern fehlgeschlagen",
"username_taken_error": "Dieser Benutzername ist bereits vergeben.",
"username_immutable_error": "Ihr Benutzername ist bereits gesetzt und kann hier nicht geändert werden. Wenden Sie sich an einen Administrator, wenn Sie umbenennen möchten.",
"change_password": "Passwort ändern",
"current_password": "Aktuelles Passwort",
"new_password": "Neues Passwort",
"min_8_chars": "Mindestens 8 Zeichen",
"confirm_password": "Neues Passwort bestätigen",
"update_password": "Passwort aktualisieren",
"updating": "Aktualisierung…",
"password_updated": "Passwort erfolgreich aktualisiert",
"passwords_no_match": "Passwörter stimmen nicht überein",
"password_too_short": "Passwort muss mindestens 8 Zeichen haben",
"password_change_failed": "Passwort ändern fehlgeschlagen",
"error_network": "Netzwerkfehler: {{message}}",
"error_label_required": "Bitte Bezeichnung eingeben",
"error_create_pw": "App-Passwort erstellen fehlgeschlagen",
"confirm_revoke": "App-Passwort \"{{label}}\" widerrufen? Clients werden nicht mehr funktionieren.",
"error_revoke": "Widerrufen fehlgeschlagen",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Wird hochgeladen...",
"files": "Dateien",
"complete": "{{count}} / {{total}} hochgeladen"
},
"storage_quota_exceeded": "Speicherplatz erschöpft",
"sharedwithme": {
"pageTitle": "Mit mir geteilt",
"pageDescription": "Dateien und Ordner, die andere Benutzer mit Ihnen geteilt haben",
"emptyStateTitle": "Noch nichts mit Ihnen geteilt",
"emptyStateDesc": "Elemente, die andere Benutzer mit Ihnen teilen, erscheinen hier",
"loadMore": "Mehr laden",
"sharedBy": "Geteilt von",
"colName": "Name",
"colType": "Typ",
"colSharedBy": "Geteilt von",
"colDate": "Datum der Freigabe",
"colPermissions": "Berechtigungen"
},
"groupby": {
"none": "Keine",
"title": "Gruppieren nach",
"owner": "Eigentümer",
"shareDate": "Freigabedatum",
"type": "Typ",
"type.folders": "Ordner",
"accessedAt": "Zugriffsdatum",
"modifiedAt": "Änderungsdatum",
"createdAt": "Erstellungsdatum",
"size": "Größe",
"favoriteDate": "Datum der Markierung",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "Neu"
},
"dateBucket": {
"today": "Heute",
"last7days": "Letzte 7 Tage",
"last30days": "Letzte 30 Tage"
},
"groups": {
"title": "Gruppen verwalten",
"create_button": "Gruppe erstellen",
"create_dialog_title": "Neue Gruppe",
"edit_dialog_title": "Gruppe umbenennen",
"name_label": "Name",
"name_placeholder": "engineering",
"description_label": "Beschreibung (optional)",
"members_section": "Mitglieder",
"add_member_placeholder": "Benutzer oder Gruppe hinzufügen…",
"no_members": "Noch keine Mitglieder.",
"remove_member": "Entfernen",
"delete_group": "Gruppe löschen",
"delete_confirm": "Die Gruppe „{name}\" löschen? Auf diese Gruppe verweisende Berechtigungen werden widerrufen.",
"empty_state": "Noch keine Gruppen.",
"load_more": "Mehr laden",
"back_to_list": "Zurück",
"loading": "Wird geladen…",
"virtual_badge": "System",
"member_count_zero": "Keine Mitglieder",
"member_count_one": "1 Mitglied",
"member_count_other": "{count} Mitglieder",
"delete_confirm_label": "Tippe den Gruppennamen zur Bestätigung ein:",
"delete_confirm_mismatch": "Tippe den Gruppennamen exakt zur Bestätigung ein.",
"virtual_internal_name": "Intern",
"members_loading": "Mitglieder werden geladen…",
"members_empty": "Keine Mitglieder",
"virtual_internal_explanation": "Jeder interne Benutzer auf diesem Server"
},
"myshares": {
"copyLink": "Link kopieren",
"deleteLink": "Link löschen",
"notifyByEmail": "Per E-Mail benachrichtigen",
"notifyFailed": "Benachrichtigung konnte nicht gesendet werden.",
"notifyGroupMembers": "Gruppenmitglieder benachrichtigen",
"notifyRateLimited": "Zu viele Benachrichtigungen für diesen Empfänger — versuchen Sie es später erneut.",
"removeAccess": "Zugriff entfernen",
"resendInvitation": "Einladungs-E-Mail erneut senden"
},
"sort": {
"asc": "aufsteigend",
"desc": "absteigend"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}
File diff suppressed because it is too large Load Diff
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "Este enlace de inicio de sesión ya no es válido",
"expired_body": "Es posible que el enlace haya expirado o ya se haya utilizado. Podemos enviarte uno nuevo — llegará a tu bandeja de entrada en unos segundos.",
"resend_to": "Enviar un nuevo enlace a {{email}}",
"generic_unavailable": "Este enlace de inicio de sesión ya no es válido. Es posible que ya se haya usado o que haya expirado. Solicita uno nuevo desde la página de inicio de sesión.",
"service_unavailable": "El inicio de sesión por enlace mágico no está habilitado en este servidor.",
"internal_error": "Algo salió mal al iniciar sesión. Por favor, inténtalo de nuevo.",
"resend_failure": "Algo salió mal al enviar el enlace. Por favor, inténtalo de nuevo.",
"cross_browser_title": "¿Continuar el inicio de sesión en este dispositivo?",
"cross_browser_body": "Has abierto este enlace de inicio de sesión en un navegador o dispositivo diferente del que lo solicitó.",
"cross_browser_warning": "Si solicitaste este enlace, es seguro continuar. Si no, cierra esta página — hacer clic en Continuar iniciaría sesión a otra persona en tu cuenta.",
"cross_browser_continue": "Continuar e iniciar sesión",
"resend_confirmation_title": "Revisa tu bandeja de entrada",
"resend_confirmation_body": "Si el enlace de inicio de sesión pertenecía a una cuenta activa, se acaba de enviar uno nuevo. Por favor, revisa tu bandeja de entrada.",
"return_link": "Volver a OxiCloud"
},
"email": {
"invitation": {
"subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud",
"body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nÁbrelo haciendo clic en el enlace de abajo:\n{{link}}\n\nEl enlace es de un solo uso y expira en {{ttl_hours}} horas.\nSi no esperabas esta invitación, puedes ignorar este mensaje.\n\n— OxiCloud"
},
"login": {
"subject": "Inicia sesión en OxiCloud",
"body": "Hola,\n\nUsa el enlace de abajo para iniciar sesión en OxiCloud. El enlace es de un solo uso y expira en {{ttl_minutes}} minutos. Ábrelo en el mismo dispositivo donde lo solicitaste.\n\n{{link}}\n\nSi no solicitaste este enlace de inicio de sesión, puedes ignorar este mensaje — no se necesita ninguna acción adicional.\n\n— OxiCloud"
},
"kind_file": "archivo",
"kind_folder": "carpeta",
"english_fallback_divider": "--- Versión en inglés a continuación ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} ha compartido un {{kind}} contigo en OxiCloud",
"body": "{{inviter_full}} ha compartido un {{kind}} contigo en OxiCloud.\n\nAbre OxiCloud para ver tu nuevo recurso compartido:\n{{login_link}}\n\nPuede que tengas más recursos compartidos nuevos de {{inviter}} — inicia sesión para ver todos tus elementos compartidos.\n\n— OxiCloud\n\nRecibes este mensaje porque tienes una cuenta de OxiCloud y la preferencia de notificación de recursos compartidos está activada. Puedes desactivarla en tu perfil (Enviarme un correo cuando alguien comparta conmigo)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "Sistema de almacenamiento en la nube minimalista"
},
"nav": {
"files": "Archivos",
"shared": "Compartidos",
"recent": "Recientes",
"favorites": "Favoritos",
"photos": "Fotos",
"music": "Música",
"trash": "Papelera",
"sharedwithme": "Compartidos conmigo"
},
"photos": {
"empty_state": "Aún no hay fotos",
"empty_hint": "Sube imágenes o videos para verlos aquí",
"items_selected": "seleccionados",
"view_daily": "Día",
"view_monthly": "Mes",
"view_yearly": "Año"
},
"music": {
"create_playlist": "Crear Lista",
"playlists": "Listas",
"no_playlists": "Sin listas aún",
"empty_hint": "Crea tu primera lista para empezar a organizar tu música",
"select_playlist": "Selecciona una lista",
"select_hint": "Elige una lista de la barra lateral o crea una nueva",
"add_tracks": "Añadir Pistas",
"no_tracks": "No hay pistas en esta lista",
"unknown_artist": "Artista Desconocido",
"unknown_title": "Desconocido",
"confirm_delete": "¿Eliminar esta lista?",
"playlist_name": "Nombre de la lista",
"create": "Crear",
"delete": "Eliminar",
"share": "Compartir",
"edit": "Editar",
"play_all": "Reproducir Todo",
"shuffle": "Aleatorio",
"repeat": "Repetir",
"repeat_one": "Repetir Una",
"queue": "Cola",
"queue_empty": "Cola vacía",
"not_playing": "No reproduciendo",
"play": "Reproducir",
"pause": "Pausar",
"previous": "Anterior",
"next": "Siguiente",
"volume": "Volumen",
"mute": "Silenciar",
"unmute": "Activar sonido",
"title": "Título",
"artist": "Artista",
"album": "Álbum",
"tracks": "pistas",
"add": "Añadir",
"added": "¡Añadido!",
"added_to_playlist": "añadido a la lista",
"add_to_playlist": "Añadir a playlist",
"load_error": "Error al cargar listas",
"add_error": "No se pudieron añadir las pistas",
"no_playlists_yet": "No hay listas aún. ¡Crea una primero!",
"selected_files": "Seleccionados:",
"share_with_user": "ID de usuario o email",
"playback_error": "Error de reproducción",
"error": "Error",
"remove": "Eliminar",
"track_removed": "Pista eliminada",
"manage_shares": "Gestionar compartidos",
"no_shares": "Sin compartidos aún",
"remove_share": "Eliminar compartido",
"can_write": "Puede editar",
"read_only": "Solo lectura",
"public": "Pública",
"private": "Privada",
"toggle_public": "Visibilidad",
"make_public": "Hacer pública",
"make_private": "Hacer privada",
"set_cover": "Establecer portada",
"cover_updated": "Portada actualizada",
"search_audio": "Buscar archivos de audio…",
"no_audio_files": "No se encontraron archivos de audio",
"selected": "seleccionados",
"loading": "Cargando…",
"search_error": "No se pudieron cargar los archivos de audio",
"adding": "Añadiendo…"
},
"share": {
"dialogTitle": "Compartir Enlace",
"linkLabel": "Enlace compartido:",
"copyLink": "Copiar",
"permissions": "Permisos:",
"permissionRead": "Lectura",
"permissionWrite": "Escritura",
"permissionReshare": "Recompartir",
"password": "Protección con contraseña:",
"generatePassword": "Generar",
"expiration": "Fecha de caducidad:",
"update": "Actualizar compartido",
"remove": "Eliminar compartido",
"notifyTitle": "Enviar notificación",
"notifyEmailLabel": "Dirección de correo:",
"notifyMessageLabel": "Mensaje (opcional):",
"notifySend": "Enviar notificación",
"shareWithOthers": "Compartir con otros",
"sharePublicly": "Compartir públicamente",
"shareSettings": "Configuración de compartido",
"shareCopied": "Enlace copiado al portapapeles",
"shareCreated": "Enlace compartido creado correctamente",
"shareUpdated": "Configuración de compartido actualizada",
"shareRemoved": "Compartido eliminado correctamente",
"inviteByEmail": "Invitar por correo — se enviará una invitación",
"directoryUnavailable": "Directorio de usuarios no disponible",
"linkNamePlaceholder": "Nombre del enlace (opcional)",
"newLink": "Nuevo enlace",
"noExpiry": "Sin caducidad",
"pending": "Pendiente",
"people": "Personas",
"publicLinks": "Enlaces públicos",
"role": {
"canEdit": "Puede editar",
"canManage": "Puede gestionar",
"canView": "Puede ver"
},
"searchPlaceholder": "Buscar personas…",
"shareOf": "Compartir:",
"sharedLink": "Enlace compartido"
},
"share_dialogTitle": "Compartir Enlace",
"share_linkLabel": "Enlace compartido:",
"share_copyLink": "Copiar",
"share_permissions": "Permisos:",
"share_permissionRead": "Lectura",
"share_permissionWrite": "Escritura",
"share_permissionReshare": "Recompartir",
"share_password": "Protección con contraseña:",
"share_generatePassword": "Generar",
"share_expiration": "Fecha de caducidad:",
"share_update": "Actualizar compartido",
"share_remove": "Eliminar compartido",
"share_notifyTitle": "Enviar notificación",
"share_notifyEmailLabel": "Dirección de correo:",
"share_notifyMessageLabel": "Mensaje (opcional):",
"share_notifySend": "Enviar notificación",
"shared": {
"backToFiles": "Volver a Archivos",
"pageTitle": "Recursos Compartidos",
"pageDescription": "Administra tus archivos y carpetas compartidos",
"filterType": "Tipo:",
"filterAll": "Todos",
"filterFiles": "Archivos",
"filterFolders": "Carpetas",
"sortBy": "Ordenar por:",
"sortByName": "Nombre",
"sortByDate": "Fecha compartido",
"sortByExpiration": "Caducidad",
"search": "Buscar",
"colName": "Nombre",
"colType": "Tipo",
"colDateShared": "Fecha compartido",
"colExpiration": "Caducidad",
"colPermissions": "Permisos",
"colPassword": "Contraseña",
"colActions": "Acciones",
"emptyStateTitle": "Aún no hay recursos compartidos",
"emptyStateDesc": "Cuando compartas archivos o carpetas, aparecerán aquí",
"goToFiles": "Ir a Archivos",
"typeFile": "Archivo",
"typeFolder": "Carpeta",
"noExpiration": "Sin caducidad",
"hasPassword": "Sí",
"noPassword": "No",
"editShare": "Editar compartido",
"notifyShare": "Notificar a alguien",
"copyLink": "Copiar enlace",
"removeShare": "Eliminar compartido",
"linkCopied": "¡Enlace copiado al portapapeles!",
"linkCopyFailed": "Error al copiar el enlace",
"itemUpdated": "Configuración de compartido actualizada",
"itemRemoved": "Compartido eliminado correctamente",
"invalidEmail": "Por favor, introduce una dirección de correo válida",
"notificationSent": "Notificación enviada correctamente",
"notificationFailed": "Error al enviar la notificación",
"shared_backToFiles": "Volver a Archivos",
"shared_pageTitle": "Recursos Compartidos",
"shared_pageDescription": "Administra tus archivos y carpetas compartidos",
"shared_filterType": "Tipo:",
"shared_filterAll": "Todos",
"shared_filterFiles": "Archivos",
"shared_filterFolders": "Carpetas",
"shared_sortBy": "Ordenar por:",
"shared_sortByName": "Nombre",
"shared_sortByDate": "Fecha compartido",
"shared_sortByExpiration": "Caducidad",
"shared_search": "Buscar",
"shared_colName": "Nombre",
"shared_colType": "Tipo",
"shared_colDateShared": "Fecha compartido",
"shared_colExpiration": "Caducidad",
"shared_colPermissions": "Permisos",
"shared_colPassword": "Contraseña",
"shared_colActions": "Acciones",
"shared_emptyStateTitle": "Aún no hay recursos compartidos",
"shared_emptyStateDesc": "Cuando compartas archivos o carpetas, aparecerán aquí",
"shared_goToFiles": "Ir a Archivos",
"shared_typeFile": "Archivo",
"shared_typeFolder": "Carpeta",
"shared_noExpiration": "Sin caducidad",
"shared_hasPassword": "Sí",
"shared_noPassword": "No",
"shared_editShare": "Editar compartido",
"shared_notifyShare": "Notificar a alguien",
"shared_copyLink": "Copiar enlace",
"shared_removeShare": "Eliminar compartido",
"shared_linkCopied": "¡Enlace copiado al portapapeles!",
"shared_linkCopyFailed": "Error al copiar el enlace",
"shared_itemUpdated": "Configuración de compartido actualizada",
"shared_itemRemoved": "Compartido eliminado correctamente",
"shared_invalidEmail": "Por favor, introduce una dirección de correo válida",
"shared_notificationSent": "Notificación enviada correctamente",
"shared_notificationFailed": "Error al enviar la notificación"
},
"actions": {
"search": "Buscar archivos...",
"new_folder": "Nueva carpeta",
"upload": "Subir",
"upload_files": "Subir archivos",
"upload_folder": "Subir carpeta",
"upload.uploading": "Subiendo...",
"upload.complete": "{count} / {total} subidos",
"upload.files": "archivos",
"rename": "Renombrar",
"move": "Mover a...",
"move_to": "Mover a",
"delete": "Eliminar",
"download": "Descargar",
"view": "Ver",
"cancel": "Cancelar",
"confirm": "Confirmar",
"share": "Compartir",
"favorite": "Añadir a favoritos",
"unfavorite": "Quitar de favoritos",
"copy": "Copiar",
"notify": "Notificar",
"send": "Enviar",
"clear_recent": "Limpiar recientes",
"logout": "Cerrar sesión",
"create": "Crear",
"search_btn": "Buscar",
"close": "Cerrar",
"delete_permanently": "Eliminar permanentemente",
"empty_trash": "Vaciar papelera",
"open_parent_folder": "Ir a la carpeta padre",
"add": "Añadir",
"apply": "Aplicar",
"clear": "Limpiar",
"remove": "Quitar"
},
"user_menu": {
"appearance": "Apariencia",
"about": "Acerca de OxiCloud",
"about_description": "Plataforma de almacenamiento en la nube creada con Rust y Arquitectura Limpia. Rápida, segura y privada.",
"admin_panel": "Panel de administración",
"profile": "Mi perfil",
"role_user": "Usuario",
"theme": {
"light": "Claro",
"dark": "Oscuro",
"auto": "Como el sistema"
},
"manage_groups": "Gestionar grupos"
},
"files": {
"name": "Nombre",
"type": "Tipo",
"size": "Tamaño",
"modified": "Modificado",
"no_files": "No hay archivos en esta carpeta",
"empty_hint": "Sube archivos o crea carpetas para comenzar",
"loading": "Cargando archivos…",
"view_grid": "Vista de cuadrícula",
"view_list": "Vista de lista",
"file_types": {
"document": "Documento",
"image": "Imagen",
"video": "Video",
"audio": "Audio",
"pdf": "PDF",
"text": "Texto",
"folder": "Carpeta",
"spreadsheet": "Hoja de cálculo",
"presentation": "Presentación",
"archive": "Archivo comprimido",
"installer": "Instalador",
"code": "Código"
},
"owner": "Propietario"
},
"dialogs": {
"rename_folder": "Renombrar carpeta",
"rename_file": "Renombrar archivo",
"new_name": "Nuevo nombre",
"new_folder_title": "Nueva carpeta",
"folder_name": "Nombre de la carpeta",
"folder_placeholder": "Mi carpeta",
"rename_title": "Renombrar",
"move_file": "Mover archivo",
"move_folder": "Mover carpeta",
"select_destination": "Selecciona la carpeta destino:",
"select_this_folder": "Seleccionar esta carpeta",
"go_to_parent": ".. (carpeta superior)",
"no_subfolders": "Sin subcarpetas",
"root": "Raíz",
"delete_confirmation": "¿Estás seguro de que quieres eliminar",
"and_contents": "y todo su contenido",
"no_undo": "Esta acción no se puede deshacer",
"confirm_title": "Confirmar acción",
"confirm_delete": "Mover a papelera",
"confirm_delete_file": "¿Estás seguro de que quieres mover a la papelera el archivo \"{{name}}\"?",
"confirm_delete_folder": "¿Estás seguro de que quieres mover a la papelera la carpeta \"{{name}}\" y todo su contenido?",
"confirm_permanent_delete": "Eliminar permanentemente",
"confirm_permanent_delete_msg": "¿Estás seguro de que quieres eliminar permanentemente este elemento? Esta acción no se puede deshacer.",
"confirm_empty_trash": "Vaciar papelera",
"confirm_delete_share": "Eliminar enlace compartido",
"confirm_delete_share_msg": "¿Estás seguro de que quieres eliminar este enlace compartido?",
"share_file": "Compartir Archivo",
"share_folder": "Compartir Carpeta",
"existing_shares": "Compartidos Existentes",
"share_options": "Opciones de Compartición",
"password": "Contraseña",
"expiration": "Caducidad",
"permissions": "Permisos",
"generated_link": "Enlace Generado",
"notify": "Enviar Notificación",
"recipient": "Destinatario",
"message": "Mensaje",
"move_to_home": "Mover a la carpeta de inicio"
},
"dropzone": {
"drag_files": "Arrastra archivos aquí o haz clic para seleccionar",
"drop_files": "Suelta los archivos para subirlos"
},
"permissions": {
"read": "Lectura",
"write": "Escritura",
"reshare": "Recompartir"
},
"errors": {
"file_not_found": "Archivo no encontrado",
"folder_not_found": "Carpeta no encontrada",
"delete_error": "Error al eliminar",
"upload_error": "Error al subir el archivo",
"rename_error": "Error al renombrar",
"move_error": "Error al mover",
"empty_name": "El nombre no puede estar vacío",
"name_exists": "Ya existe un archivo o carpeta con ese nombre",
"generic_error": "Ha ocurrido un error",
"group_name_invalid": "El nombre del grupo debe seguir el formato de prefijo de correo (letras, dígitos, punto, guión, guion bajo; 1–64 caracteres).",
"group_cycle": "Este miembro creaería una referencia circular entre grupos.",
"group_depth_exceeded": "Esta profundidad de anidamiento excede el máximo permitido (8).",
"group_virtual_immutable": "El grupo «Internal» es gestionado por el sistema y no se puede modificar.",
"group_not_found": "Grupo no encontrado.",
"group_name_taken": "Ya existe un grupo con este nombre."
},
"breadcrumb": {
"home": "Inicio"
},
"trash": {
"empty_trash": "Vaciar papelera",
"empty_state": "La papelera está vacía",
"original_location": "Ubicación original",
"deleted_date": "Fecha de eliminación",
"remaining": "Restante",
"actions": "Acciones",
"restore": "Restaurar",
"delete_permanently": "Eliminar permanentemente",
"empty_confirm": "¿Estás seguro de que quieres vaciar la papelera? Esta acción eliminará permanentemente todos los elementos.",
"groupby": {
"remaining_days": "Días restantes",
"trashed_time": "Fecha de eliminación"
}
},
"daysRemaining": {
"expired": "Caducado",
"today": "Hoy",
"tomorrow": "Mañana",
"inDays": "{{count}} días"
},
"expiryChip": {
"never": "Nunca caduca",
"expired": "Caducado",
"today": "Caduca hoy",
"tomorrow": "Caduca mañana",
"inDays": "Caduca en {{count}} días",
"onDate": "Caduca el {{date}}"
},
"auth": {
"login_title": "Iniciar sesión",
"username": "Usuario",
"username_placeholder": "Ingresa tu nombre de usuario",
"login_identifier": "Usuario o correo electrónico",
"login_identifier_placeholder": "Ingresa tu usuario o correo electrónico",
"password": "Contraseña",
"password_placeholder": "Ingresa tu contraseña",
"login_button": "Iniciar sesión",
"no_account": "¿No tienes cuenta?",
"register": "Regístrate",
"admin_setup": "¿Primera vez?",
"setup": "Configurar administrador",
"register_title": "Crear cuenta",
"email": "Email",
"email_placeholder": "Ingresa tu email",
"confirm_password": "Confirmar contraseña",
"confirm_password_placeholder": "Confirma tu contraseña",
"register_button": "Crear cuenta",
"have_account": "¿Ya tienes cuenta?",
"login": "Iniciar sesión",
"setup_title": "Configuración inicial",
"setup_step1": "Admin",
"setup_step2": "Sistema",
"setup_step3": "Completado",
"admin_username": "Usuario administrador",
"admin_email": "Email administrador",
"admin_password": "Contraseña administrador",
"create_admin": "Crear administrador",
"back_to_login": "¿Ya está configurado?",
"admin_success": "¡Cuenta de administrador creada con éxito! Ahora puedes iniciar sesión.",
"account_success": "¡Cuenta creada con éxito! Ahora puedes iniciar sesión.",
"passwords_mismatch": "Las contraseñas no coinciden",
"admin_create_error": "Error al crear cuenta de administrador",
"or": "o",
"sso_login": "Iniciar sesión con SSO",
"sso_login_provider": "Iniciar sesión con {{provider}}",
"magicLinkHint": "¿Sin contraseña? Introduce tu correo electrónico y te enviaremos un enlace de inicio de sesión único.",
"magicLinkEmailLabel": "Correo electrónico",
"magicLinkEmailPlaceholder": "tu@ejemplo.com",
"magicLinkSubmit": "Enviar enlace de inicio de sesión",
"magicLinkSent": "Si existe una cuenta para ese correo, se ha enviado un enlace de inicio de sesión. Revisa tu bandeja de entrada.",
"magicLinkUnavailable": "El inicio de sesión por correo electrónico no está disponible en este servidor.",
"magicLinkNetworkError": "No se pudo conectar con el servidor: {{message}}",
"magicLinkToggle": "¿Sin contraseña? Recíbelo por correo",
"passwordsMatch": "Las contraseñas coinciden",
"capsLock": "Bloq Mayús activado"
},
"storage": {
"title": "Almacenamiento",
"calculating": "Calculando...",
"used": "{{percentage}}% usado ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "Este tipo de archivo no se puede previsualizar.",
"download_file": "Descargar archivo",
"zoom_in": "Acercar",
"zoom_out": "Alejar",
"zoom_reset": "Restablecer zoom"
},
"language_selector": {
"title": "¡Bienvenido!",
"subtitle": "Selecciona tu idioma para continuar",
"continue": "Continuar",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "Aún no hay favoritos",
"empty_hint": "Marca archivos o carpetas con estrella para añadirlos a favoritos",
"add": "Añadir a favoritos",
"remove": "Quitar de favoritos",
"added_title": "Añadido a favoritos",
"added_msg": "añadido a favoritos",
"removed_title": "Quitado de favoritos",
"removed_msg": "quitado de favoritos"
},
"recent": {
"title": "Recientes",
"clear": "Limpiar recientes",
"accessed": "Accedido",
"empty_state": "No hay archivos recientes",
"empty_hint": "Los archivos que abras aparecerán aquí",
"loadMore": "Cargar más"
},
"notifications": {
"file_renamed": "Archivo renombrado",
"file_renamed_to": "Archivo renombrado a \"{{name}}\"",
"folder_renamed": "Carpeta renombrada",
"folder_renamed_to": "Carpeta renombrada a \"{{name}}\"",
"file_uploaded": "Archivo subido",
"file_deleted": "Archivo movido a papelera",
"folder_deleted": "Carpeta movida a papelera",
"item_deleted_permanently": "Elemento eliminado permanentemente",
"trash_emptied": "Papelera vaciada correctamente",
"title": "Notificaciones",
"empty": "Sin notificaciones",
"link_created": "Enlace creado",
"share_success": "Enlace compartido creado correctamente",
"upload_files_section_title": "Subida no disponible aquí",
"upload_files_section_body": "Ve a la sección Archivos para subir archivos"
},
"batch": {
"one_selected": "1 elemento seleccionado",
"n_selected": "{{count}} elementos seleccionados",
"confirm_delete": "¿Estás seguro de que quieres mover {{count}} elementos a la papelera?",
"move_title": "Mover {{count}} elemento(s)",
"add_favorites": "Añadir a favoritos",
"move_copy": "Mover o copiar"
},
"admin": {
"page_title": "Panel de Administración",
"back_to_app": "Volver a OxiCloud",
"loading": "Cargando…",
"access_denied": "Acceso Denegado",
"access_denied_desc": "Se requieren privilegios de administrador para acceder a este panel.",
"sign_in": "Iniciar sesión",
"tab_dashboard": "Panel",
"tab_users": "Usuarios",
"tab_oidc": "SSO / OIDC",
"total_users": "Usuarios Totales",
"active_users": "Usuarios Activos",
"admins": "Administradores",
"version": "Versión",
"storage_overview": "Resumen de Almacenamiento",
"used": "Usado",
"total_quota": "Cuota Total",
"usage_pct": "Uso %",
"users_over_80": "Usuarios >80% cuota",
"users_over_quota": "Usuarios sobre cuota",
"system": "Sistema",
"auth_label": "Auth",
"oidc_label": "OIDC",
"quotas_label": "Cuotas",
"enabled": "Habilitado",
"disabled": "Deshabilitado",
"active": "Activo",
"off": "Inactivo",
"allow_registration": "Permitir registro público",
"registration_warning": "El registro público está deshabilitado. Solo los administradores pueden crear nuevos usuarios.",
"user_management": "Gestión de Usuarios",
"create_user": "Crear Usuario",
"col_user": "Usuario",
"col_role": "Rol",
"col_auth": "Auth",
"col_status": "Estado",
"col_storage": "Almacenamiento",
"col_last_login": "Último Acceso",
"col_actions": "Acciones",
"loading_users": "Cargando usuarios…",
"failed_load_users": "Error al cargar usuarios",
"no_users_found": "No se encontraron usuarios",
"showing_users": "Mostrando {{from}}-{{to}} de {{total}}",
"prev": "Anterior",
"next": "Siguiente",
"inactive": "Inactivo",
"you_badge": "(tú)",
"local": "Local",
"never": "Nunca",
"just_now": "Ahora mismo",
"minutes_ago": "hace {{n}}m",
"hours_ago": "hace {{n}}h",
"days_ago": "hace {{n}}d",
"edit_quota_title": "Editar cuota",
"reset_password_title": "Restablecer contraseña",
"toggle_role_title": "Cambiar rol",
"deactivate_title": "Desactivar",
"activate_title": "Activar",
"delete_title": "Eliminar",
"sso_title": "Inicio de Sesión Único (OIDC / SSO)",
"enable_sso": "Habilitar autenticación SSO",
"provider_name": "Nombre del Proveedor",
"issuer_url": "URL del Emisor",
"issuer_url_hint": "URL del emisor OpenID Connect de tu proveedor de identidad",
"auto_discover": "Auto-descubrir",
"discovering": "Descubriendo…",
"client_id": "Client ID",
"client_secret": "Client Secret",
"client_secret_placeholder": "Dejar vacío para mantener el valor actual",
"secret_configured": "Ya hay un client secret configurado",
"callback_url": "URL de Callback",
"callback_url_hint": "(registrar en tu IdP)",
"advanced_settings": "Configuración Avanzada",
"scopes": "Scopes",
"auto_provision": "Auto-provisionar usuarios en el primer inicio de sesión",
"admin_groups": "Grupos de Admin",
"admin_groups_hint": "Nombres de grupos OIDC separados por comas que mapean al rol de admin",
"disable_password": "Desactivar inicio de sesión con contraseña (solo OIDC)",
"password_warning": "¡Esto impedirá TODOS los inicios de sesión con contraseña!",
"test_btn": "Probar",
"save_btn": "Guardar",
"saving": "Guardando…",
"settings_saved": "Configuración guardada — OIDC ahora está {{status}}",
"quota_modal_title": "Actualizar Cuota de Almacenamiento",
"quota_user_label": "Usuario:",
"new_quota": "Nueva Cuota",
"quota_unlimited_hint": "Establecer 0 para ilimitado",
"cancel": "Cancelar",
"create_user_title": "Crear Nuevo Usuario",
"username_label": "Nombre de usuario",
"username_placeholder": "juanperez",
"username_hint": "3–32 caracteres",
"password_label": "Contraseña",
"password_placeholder": "Mín 8 caracteres",
"email_label": "Correo",
"email_optional": "(opcional)",
"email_placeholder": "usuario@ejemplo.com (auto-generado si vacío)",
"role_label": "Rol",
"role_user": "Usuario",
"role_admin": "Admin",
"quota_label": "Cuota",
"creating": "Creando…",
"reset_pw_title": "Restablecer Contraseña",
"new_password_label": "Nueva Contraseña",
"resetting": "Restableciendo…",
"reset_btn": "Restablecer",
"confirm_role_change": "¿Cambiar rol a {{role}}?",
"confirm_deactivate": "¿Estás seguro de que quieres desactivar este usuario?",
"confirm_activate": "¿Estás seguro de que quieres activar este usuario?",
"confirm_delete_user": "¿ELIMINAR usuario \"{{name}}\"? ¡Esto no se puede deshacer!",
"confirm_action": "Confirmar Acción",
"confirm_yes": "Confirmar",
"confirm_no": "Cancelar",
"error_username_short": "El nombre de usuario debe tener al menos 3 caracteres",
"error_password_short": "La contraseña debe tener al menos 8 caracteres",
"error_generic": "Error",
"error_network": "Error de red: {{message}}",
"error_create_user": "Error al crear usuario",
"tab_storage": "Almacenamiento",
"storage_title": "Backend de Almacenamiento",
"storage_current_backend": "Backend Activo",
"storage_total_blobs": "Total de Blobs",
"storage_total_size": "Tamaño Total",
"storage_dedup_ratio": "Ratio de Dedup",
"storage_backend": "Tipo de Backend",
"storage_local": "Sistema de Archivos Local",
"storage_s3": "Compatible con S3",
"storage_provider_preset": "Proveedor Preconfigurado",
"storage_preset_custom": "Personalizado",
"storage_endpoint_url": "URL del Endpoint",
"storage_endpoint_hint": "Dejar vacío para usar Amazon S3 por defecto",
"storage_bucket": "Bucket",
"storage_region": "Región",
"storage_access_key": "Access Key ID",
"storage_secret_key": "Secret Access Key",
"storage_secret_configured": "Ya hay una clave secreta configurada",
"storage_key_placeholder": "Dejar vacío para mantener el valor actual",
"storage_path_style": "Forzar Path Style",
"storage_path_style_hint": "Requerido para MinIO y algunos proveedores compatibles con S3",
"storage_test_connection": "Probar Conexión",
"storage_test_success": "Conexión exitosa",
"storage_test_failure": "Conexión fallida",
"storage_save": "Guardar",
"storage_saved": "Configuración de almacenamiento guardada correctamente",
"storage_migration": "Migración de Backend",
"storage_migration_coming_soon": "La migración de backend estará disponible en una futura actualización.",
"migration_status_label": "Estado:",
"migration_start": "Iniciar Migración",
"migration_pause": "Pausar",
"migration_resume": "Reanudar",
"migration_verify": "Verificar Integridad",
"migration_complete": "Finalizar",
"migration_started": "Migración iniciada",
"migration_paused_msg": "Migración pausada",
"migration_resumed_msg": "Migración reanudada",
"migration_completed_msg": "Migración finalizada. Reinicia el servidor para usar el nuevo backend.",
"migration_verifying": "Verificando…",
"migration_verify_passed": "Verificación exitosa",
"migration_verify_failed": "Verificación fallida",
"migration_failed_blobs": "blobs fallidos",
"testing": "Probando…",
"smtp_disabled": "Desactivado (host no configurado)",
"smtp_enabled": "Activado",
"smtp_enabled_label": "Estado",
"smtp_intro": "SMTP se configura exclusivamente a través de variables de entorno (OXICLOUD_SMTP_*). Los valores siguientes se leen del servidor en ejecución — para modificarlos, edita el entorno y reinicia OxiCloud.",
"smtp_not_configured": "SMTP no está configurado en este servidor.",
"smtp_send_failed": "Fallo al enviar.",
"smtp_send_test": "Enviar correo de prueba",
"smtp_sending": "Enviando…",
"smtp_sent": "Correo de prueba enviado.",
"smtp_server_code": "Respuesta del servidor",
"smtp_test_intro": "Envía un mensaje de diagnóstico predefinido al destinatario indicado abajo e informa de la respuesta del servidor SMTP para que puedas cruzarla con los registros de tu relay.",
"smtp_test_missing_to": "Introduce una dirección de destinatario.",
"smtp_test_title": "Enviar correo de prueba",
"smtp_test_to": "Dirección del destinatario",
"smtp_title": "Correo saliente (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "Perfil",
"back_to_app": "Volver a OxiCloud",
"loading": "Cargando…",
"not_authenticated": "No Autenticado",
"not_authenticated_desc": "Inicia sesión para ver tu perfil.",
"sign_in": "Iniciar sesión",
"role_admin": "Administrador",
"role_user": "Usuario",
"account_details": "Detalles de la Cuenta",
"username": "Nombre de usuario",
"email": "Correo electrónico",
"role": "Rol",
"last_login": "Último acceso",
"storage": "Almacenamiento",
"used": "Usado",
"quota": "Cuota",
"usage": "Uso",
"unlimited": "Ilimitado",
"app_passwords": "Contraseñas de Aplicación",
"app_pw_desc": "Genera contraseñas para clientes WebDAV, CalDAV y CardDAV. Cada contraseña se muestra solo una vez.",
"app_pw_label_placeholder": "Etiqueta (ej. Thunderbird, macOS)",
"generate": "Generar",
"generating": "Generando…",
"new_password_for": "Nueva contraseña para",
"copy_warning": "Copia esta contraseña ahora. No podrás verla de nuevo.",
"copy_to_clipboard": "Copiar al portapapeles",
"col_label": "Etiqueta",
"col_created": "Creado",
"col_last_used": "Último uso",
"col_status": "Estado",
"active": "Activa",
"revoked": "Revocada",
"revoke_title": "Revocar",
"no_app_passwords": "Aún no hay contraseñas de aplicación.",
"client_sessions": "Sesiones de cliente",
"client_sessions_desc": "Generadas automáticamente al conectar un cliente compatible con Nextcloud.",
"col_client": "Cliente",
"never": "Nunca",
"just_now": "Ahora mismo",
"minutes_ago": "hace {{n}} min",
"hours_ago": "hace {{n}}h",
"days_ago": "hace {{n}} días",
"edit_profile": "Editar perfil",
"edit_oidc_managed": "Para cambiar tu información (nombre, apellidos, foto de perfil, …), actualízala en tu proveedor de identidad. Los cambios se aplicarán en tu próximo inicio de sesión.",
"username_claim_hint": "Entre 2 y 64 caracteres, letras / dígitos / punto / guion / subrayado. Una vez elegido, el nombre de usuario no se puede cambiar (los clientes DAV/NextCloud dependen de él).",
"username_already_claimed": "Nombre de usuario fijado y no modificable (los clientes DAV/NextCloud dependen de él).",
"given_name": "Nombre",
"family_name": "Apellidos",
"notify_on_share": "Enviarme un correo cuando alguien comparta conmigo",
"notify_on_share_hint": "Cuando esté desmarcado, los recursos compartidos seguirán apareciendo en tu cuenta — simplemente no recibirás un correo sobre ellos.",
"save_profile": "Guardar cambios",
"profile_saved": "Perfil actualizado",
"profile_no_changes": "Sin cambios que guardar.",
"profile_save_failed": "Error al guardar",
"username_taken_error": "Ese nombre de usuario ya está en uso.",
"username_immutable_error": "Tu nombre de usuario ya está fijado y no se puede cambiar aquí. Contacta con un administrador si necesitas renombrarlo.",
"change_password": "Cambiar Contraseña",
"current_password": "Contraseña Actual",
"new_password": "Nueva Contraseña",
"min_8_chars": "Al menos 8 caracteres",
"confirm_password": "Confirmar Nueva Contraseña",
"update_password": "Actualizar Contraseña",
"updating": "Actualizando…",
"password_updated": "Contraseña actualizada correctamente",
"passwords_no_match": "Las contraseñas no coinciden",
"password_too_short": "La contraseña debe tener al menos 8 caracteres",
"password_change_failed": "Error al cambiar la contraseña",
"error_network": "Error de red: {{message}}",
"error_label_required": "Introduce una etiqueta",
"error_create_pw": "Error al crear contraseña de aplicación",
"confirm_revoke": "¿Revocar contraseña \"{{label}}\"? Los clientes que la usen dejarán de funcionar.",
"error_revoke": "Error al revocar contraseña",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Subiendo...",
"files": "archivos",
"complete": "{{count}} / {{total}} subidos"
},
"storage_quota_exceeded": "Cuota de almacenamiento superada",
"sharedwithme": {
"pageTitle": "Compartido conmigo",
"pageDescription": "Archivos y carpetas que otros usuarios han compartido contigo",
"emptyStateTitle": "Aún no hay nada compartido contigo",
"emptyStateDesc": "Los elementos que otros usuarios compartan contigo aparecerán aquí",
"loadMore": "Cargar más",
"sharedBy": "Compartido por",
"colName": "Nombre",
"colType": "Tipo",
"colSharedBy": "Compartido por",
"colDate": "Fecha de compartición",
"colPermissions": "Permisos"
},
"groupby": {
"none": "Ninguno",
"title": "Agrupar por",
"owner": "Propietario",
"shareDate": "Fecha de compartición",
"type": "Tipo",
"type.folders": "Carpetas",
"accessedAt": "Fecha de acceso",
"modifiedAt": "Fecha de modificación",
"createdAt": "Fecha de creación",
"size": "Tamaño",
"favoriteDate": "Fecha de favorito",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "Nuevo"
},
"dateBucket": {
"today": "Hoy",
"last7days": "Últimos 7 días",
"last30days": "Últimos 30 días"
},
"groups": {
"title": "Gestionar grupos",
"create_button": "Crear grupo",
"create_dialog_title": "Nuevo grupo",
"edit_dialog_title": "Renombrar grupo",
"name_label": "Nombre",
"name_placeholder": "ingenieria",
"description_label": "Descripción (opcional)",
"members_section": "Miembros",
"add_member_placeholder": "Añadir un usuario o grupo…",
"no_members": "Aún no hay miembros.",
"remove_member": "Eliminar",
"delete_group": "Eliminar grupo",
"delete_confirm": "¿Eliminar el grupo «{name}»? Se revocarán las concesiones que hagan referencia a este grupo.",
"empty_state": "Aún no hay grupos.",
"load_more": "Cargar más",
"back_to_list": "Volver",
"loading": "Cargando…",
"virtual_badge": "Sistema",
"member_count_zero": "Sin miembros",
"member_count_one": "1 miembro",
"member_count_other": "{count} miembros",
"delete_confirm_label": "Escribe el nombre del grupo para confirmar:",
"delete_confirm_mismatch": "Escribe el nombre del grupo exactamente para confirmar.",
"virtual_internal_name": "Interno",
"members_loading": "Cargando miembros…",
"members_empty": "Sin miembros",
"virtual_internal_explanation": "Todos los usuarios internos de este servidor"
},
"myshares": {
"copyLink": "Copiar enlace",
"deleteLink": "Eliminar enlace",
"notifyByEmail": "Notificar por correo",
"notifyFailed": "No se pudo enviar la notificación.",
"notifyGroupMembers": "Notificar a los miembros del grupo",
"notifyRateLimited": "Demasiadas notificaciones para este destinatario — inténtalo más tarde.",
"removeAccess": "Quitar acceso",
"resendInvitation": "Reenviar correo de invitación"
},
"sort": {
"asc": "ascendente",
"desc": "descendente"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error al realizar la búsqueda",
"cleanupCompleted": "Limpieza completada",
"cleanupCompletedBody": "Se ha borrado el historial de archivos recientes",
"batchCopy": "Copia en lote",
"batchCopyBody": "{{success}} copiados, {{errors}} fallidos",
"itemsCopied": "Elementos copiados",
"itemsCopiedBody": "{{count}} elementos copiados correctamente",
"batchMove": "Movimiento en lote",
"batchMoveBody": "{{success}} movidos, {{errors}} fallidos",
"itemsMoved": "Elementos movidos",
"itemsMovedBody": "{{count}} elementos movidos correctamente",
"batchDelete": "Borrado en lote",
"batchDeleteBody": "{{success}} movidos a la papelera, {{errors}} fallidos",
"movedToTrash": "Movido a la papelera",
"movedToTrashBody": "{{count}} elementos movidos a la papelera",
"trashItemsError": "No se pudieron mover los elementos a la papelera",
"preparingDownload": "Preparando descarga",
"preparingDownloadBody": "Preparando la descarga…",
"downloadItemsError": "No se pudieron descargar los elementos seleccionados",
"favoritesAddError": "No se pudieron añadir los elementos a favoritos",
"invalidEmail": "Introduce una dirección de correo válida",
"notificationSendError": "No se pudo enviar la notificación",
"folderCreated": "Carpeta creada",
"folderCreatedBody": "«{{name}}» creada correctamente",
"fileMoved": "Archivo movido",
"fileMovedBody": "Archivo movido correctamente",
"fileMoveError": "Error al mover el archivo: {{error}}",
"fileMoveErrorGeneric": "Error al mover el archivo",
"folderMoved": "Carpeta movida",
"folderMovedBody": "Carpeta movida correctamente",
"folderMoveError": "Error al mover la carpeta: {{error}}",
"folderMoveErrorGeneric": "Error al mover la carpeta",
"fileCopied": "Archivo copiado",
"fileCopiedBody": "Archivo copiado correctamente",
"fileCopyError": "Error al copiar el archivo: {{error}}",
"fileCopyErrorGeneric": "Error al copiar el archivo",
"folderRenamed": "Carpeta renombrada",
"folderRenamedBody": "Carpeta renombrada a «{{name}}»",
"fileTrashed": "Archivo movido a la papelera",
"fileTrashedBody": "«{{name}}» movido a la papelera",
"fileDeleted": "Archivo eliminado",
"fileDeletedBody": "«{{name}}» eliminado correctamente",
"fileDeleteError": "Error al eliminar el archivo",
"folderTrashed": "Carpeta movida a la papelera",
"folderTrashedBody": "«{{name}}» movida a la papelera",
"folderDeleted": "Carpeta eliminada",
"folderDeletedBody": "«{{name}}» eliminada correctamente",
"folderDeleteError": "Error al eliminar la carpeta",
"itemRestored": "Elemento restaurado",
"itemRestoredBody": "Elemento restaurado correctamente",
"itemRestoreError": "Error al restaurar el elemento",
"itemDeleted": "Elemento eliminado",
"itemDeletedBody": "Elemento eliminado permanentemente",
"itemDeleteError": "Error al eliminar el elemento",
"trashEmptied": "Papelera vaciada",
"trashEmptiedBody": "La papelera se ha vaciado correctamente",
"trashEmptyError": "Error al vaciar la papelera",
"cacheCleared": "Caché limpiada",
"cacheClearedBody": "Caché de búsqueda limpiada correctamente",
"cacheClearError": "Error al limpiar la caché de búsqueda",
"wopiOpenError": "No se pudo abrir el editor de documentos.",
"linkCopied": "Enlace copiado",
"linkCopiedBody": "Enlace copiado al portapapeles",
"linkCopyError": "No se pudo copiar el enlace",
"notificationSent": "Notificación enviada",
"notificationSentBody": "Notificación enviada a {{email}}"
}
}
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "این پیوند ورود دیگر معتبر نیست",
"expired_body": "ممکن است پیوند منقضی شده یا قبلاً استفاده شده باشد. می‌توانیم پیوند جدیدی برایتان ارسال کنیم — ظرف چند ثانیه به صندوق ورودی شما می‌رسد.",
"resend_to": "ارسال پیوند جدید به {{email}}",
"generic_unavailable": "این پیوند ورود دیگر معتبر نیست. ممکن است قبلاً استفاده شده باشد یا منقضی شده باشد. پیوند جدیدی را از صفحهٔ ورود درخواست کنید.",
"service_unavailable": "ورود از طریق پیوند جادویی روی این سرور فعال نیست.",
"internal_error": "هنگام ورود خطایی رخ داد. لطفاً دوباره تلاش کنید.",
"resend_failure": "هنگام ارسال پیوند خطایی رخ داد. لطفاً دوباره تلاش کنید.",
"cross_browser_title": "آیا می‌خواهید ورود در این دستگاه ادامه یابد؟",
"cross_browser_body": "این پیوند ورود را در مرورگر یا دستگاهی متفاوت از جایی که درخواست کرده‌اید باز کرده‌اید.",
"cross_browser_warning": "اگر این پیوند را خودتان درخواست کرده‌اید، ادامه دادن ایمن است. در غیر این صورت این صفحه را ببندید — کلیک روی ادامه باعث ورود شخص دیگری به حساب شما خواهد شد.",
"cross_browser_continue": "ادامه و ورود",
"resend_confirmation_title": "صندوق ورودی خود را بررسی کنید",
"resend_confirmation_body": "اگر پیوند ورود متعلق به یک حساب فعال بوده، پیوند جدیدی هم اکنون ارسال شد. لطفاً صندوق ورودی خود را بررسی کنید.",
"return_link": "بازگشت به OxiCloud"
},
"email": {
"invitation": {
"subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت",
"body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبا کلیک روی پیوند زیر آن را باز کنید:\n{{link}}\n\nپیوند یک‌بار مصرف است و در {{ttl_hours}} ساعت منقضی می‌شود.\nاگر منتظر این دعوت نبودید، می‌توانید این پیام را نادیده بگیرید.\n\n— OxiCloud"
},
"login": {
"subject": "ورود به OxiCloud",
"body": "سلام،\n\nبرای ورود به OxiCloud از پیوند زیر استفاده کنید. پیوند یک‌بار مصرف است و در {{ttl_minutes}} دقیقه منقضی می‌شود. آن را در همان دستگاهی که درخواست کرده‌اید باز کنید.\n\n{{link}}\n\nاگر این پیوند ورود را درخواست نکرده‌اید، می‌توانید این پیام را نادیده بگیرید — اقدام دیگری لازم نیست.\n\n— OxiCloud"
},
"kind_file": "فایل",
"kind_folder": "پوشه",
"english_fallback_divider": "--- نسخهٔ انگلیسی در پایین ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت",
"body": "{{inviter_full}} یک {{kind}} را با شما در OxiCloud به اشتراک گذاشت.\n\nبرای دیدن اشتراک‌گذاری جدید خود، OxiCloud را باز کنید:\n{{login_link}}\n\nممکن است اشتراک‌گذاری‌های جدید دیگری از {{inviter}} داشته باشید — وارد شوید تا همه موارد به اشتراک گذاشته‌شده با خود را ببینید.\n\n— OxiCloud\n\nشما این پیام را دریافت می‌کنید زیرا حساب OxiCloud دارید و گزینه اعلان اشتراک‌گذاری شما روشن است. می‌توانید آن را در پروفایل خود خاموش کنید (وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "سیستم ذخیره‌سازی ابری ساده‌گرا"
},
"nav": {
"files": "پرونده‌ها",
"shared": "هم‌رسانی‌های من",
"recent": "اخیر",
"favorites": "موردعلاقه‌ها",
"photos": "عکس‌ها",
"music": "موسیقی",
"trash": "سطل زباله",
"sharedwithme": "به اشتراک‌گذاشته شده با من"
},
"photos": {
"empty_state": "هنوز عکسی نیست",
"empty_hint": "تصاویر یا ویدیوها را آپلود کنید تا اینجا نمایش داده شوند",
"items_selected": "انتخاب شده",
"view_daily": "روز",
"view_monthly": "ماه",
"view_yearly": "سال"
},
"music": {
"create_playlist": "ایجاد فهرست پخش",
"playlists": "فهرست‌های پخش",
"no_playlists": "هنوز فهرست پخشی نیست",
"select_playlist": "یک فهرست پخش انتخاب کنید",
"select_hint": "از نوار کناری یک فهرست پخش انتخاب کنید یا یکی جدید بسازید",
"add_tracks": "افزودن آهنگ‌ها",
"no_tracks": "هیچ آهنگی در این فهرست پخش نیست",
"unknown_artist": "هنرمند ناشناس",
"unknown_title": "ناشناس",
"confirm_delete": "این فهرست پخش حذف شود؟",
"playlist_name": "نام فهرست پخش",
"create": "ایجاد",
"delete": "حذف",
"share": "هم‌رسانی",
"edit": "ویرایش",
"play_all": "پخش همه",
"shuffle": "تصادفی",
"repeat": "تکرار",
"repeat_one": "تکرار یک",
"queue": "صف",
"queue_empty": "صف خالی است",
"not_playing": "در حال پخش نیست",
"play": "پخش",
"pause": "توقف",
"previous": "قبلی",
"next": "بعدی",
"volume": "صدا",
"mute": "بی‌صدا",
"unmute": "صدا فعال",
"title": "عنوان",
"artist": "هنرمند",
"album": "آلبوم",
"tracks": "آهنگ",
"add": "افزودن",
"added": "افزوده شد!",
"added_to_playlist": "به فهرست پخش افزوده شد",
"add_to_playlist": "افزودن به فهرست پخش",
"load_error": "خطا در بارگیری فهرست پخش",
"add_error": "امکان افزودن آهنگ‌ها به فهرست پخش نیست",
"no_playlists_yet": "فهرست پخشی وجود ندارد. اول یکی بسازید!",
"selected_files": "انتخاب شده:",
"error": "خطا",
"search_audio": "جستجوی فایل‌های صوتی…",
"no_audio_files": "فایل صوتی یافت نشد",
"selected": "انتخاب شده",
"loading": "در حال بارگذاری…",
"search_error": "بارگذاری فایل‌های صوتی ممکن نشد",
"adding": "در حال افزودن…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "جست‌و‌جوی پرونده‌ها..",
"new_folder": "پوشهٔ جدید",
"upload": "بارگذاری",
"upload_files": "بارگذاری پرونده‌ها",
"upload_folder": "بارگذاری پوشه",
"upload.uploading": "...در حال بارگذاری",
"upload.complete": "{count} / {total} بارگذاری شد",
"upload.files": "فایل‌ها",
"rename": "تغییر نام",
"move": "انتقال به...",
"move_to": "انتقال به",
"delete": "حذف",
"download": "بارگیری",
"view": "مشاهده",
"cancel": "لغو",
"confirm": "تأیید",
"share": "هم‌رسانی",
"favorite": "افزودن به موردعلاقه‌ها",
"unfavorite": "حذف از موردعلاقه‌ها",
"copy": "رونوشت",
"notify": "آگاه‌سازی",
"send": "ارسال",
"clear_recent": "پاک‌کردن موارد اخیر",
"logout": "خروج",
"create": "ایجاد",
"search_btn": "جست‌و‌جو",
"close": "بستن",
"delete_permanently": "Delete permanently",
"empty_trash": "Empty trash",
"open_parent_folder": "رفتن به پوشه والد",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "ظاهر",
"about": "درباره OxiCloud",
"about_description": "پلتفرم ذخیره‌سازی ابری ساخته شده با Rust و معماری تمیز. سریع، امن و خصوصی.",
"admin_panel": "پنل مدیریت",
"profile": "نمایه من",
"role_user": "کاربر",
"theme": {
"light": "روشن",
"dark": "تاریک",
"auto": "مانند سیستم"
},
"manage_groups": "مدیریت گروه‌ها"
},
"share": {
"dialogTitle": "پیوند هم‌رسانی",
"linkLabel": "پیوند هم‌سانی:",
"copyLink": "رونوشت",
"permissions": "دسترسی‌ها:",
"permissionRead": "خواندن",
"permissionWrite": "نوشتن",
"permissionReshare": "هم‌رسانی دوباره",
"password": "محافظت با گذرواژه:",
"generatePassword": "تولید",
"expiration": "تاریخ انقضا:",
"update": "به‌روزرسانی هم‌رسانی",
"remove": "پاک‌کردن هم‌رسانی",
"notifyTitle": "ارسال آگاه‌سازی",
"notifyEmailLabel": "نشانی رایانامه:",
"notifyMessageLabel": "پیام (اختیاری):",
"notifySend": "ارسال آگاه‌سازی",
"shareWithOthers": "هم‌رسانی با دیگران",
"sharePublicly": "هم‌رسانی عمومی",
"shareSettings": "تنظیمات هم‌رسانی",
"shareCopied": "پیوند به بُریده‌دان رونوشت شد",
"shareCreated": "پیوند هم‌رسانی با موفقیت ایجاد شد",
"shareUpdated": "تنظیمات هم‌رسانی با موفقیت به‌روزرسانی شد",
"shareRemoved": "هم‌رسانی با موفقیت پاک شد",
"inviteByEmail": "دعوت از طریق ایمیل — دعوت ارسال خواهد شد",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "پیوند هم‌رسانی",
"share_linkLabel": "پیوند هم‌رسانی:",
"share_copyLink": "رونوشت",
"share_permissions": "دسترسی‌ها:",
"share_permissionRead": "خواندن",
"share_permissionWrite": "نوشتن",
"share_permissionReshare": "هم‌رسانی دوباره",
"share_password": "محافظت با گذرواژه:",
"share_generatePassword": "تولید",
"share_expiration": "تاریخ انقضا:",
"share_update": "به‌روزرسانی هم‌رسانی",
"share_remove": "پاک‌کردن هم‌رسانی",
"share_notifyTitle": "ارسال آگاه‌سازی",
"share_notifyEmailLabel": "نشانی رایانامه:",
"share_notifyMessageLabel": "پیام (اختیاری):",
"share_notifySend": "ارسال آگاه‌سازی",
"shared": {
"backToFiles": "بازگشت به پرونده‌ها",
"pageTitle": "منابع هم‌رسانی شده",
"pageDescription": "مدیریت پرونده‌ها و پوشه‌های هم‌رسانی شده شما",
"filterType": "نوع:",
"filterAll": "همه",
"filterFiles": "پرونده‌ها",
"filterFolders": "پوشه‌ها",
"sortBy": "مرتب‌سازی بر اساس:",
"sortByName": "نام",
"sortByDate": "تاریخ هم‌رسانی",
"sortByExpiration": "تاریخ انقضا",
"search": "جست‌و‌جو",
"colName": "نام",
"colType": "نوع",
"colDateShared": "تاریخ هم‌رسانی",
"colExpiration": "تاریخ انقضا",
"colPermissions": "دسترسی‌ها",
"colPassword": "گذرواژه",
"colActions": "عملیات",
"emptyStateTitle": "هنوز هیچ منبعی هم‌رسانی نشده است",
"emptyStateDesc": "وقتی پرونده‌ها یا پوشه‌ها را هم‌رسانی کنید، اینجا نمایش داده می‌شوند",
"goToFiles": "رفتن به پرونده‌ها",
"typeFile": "پرونده",
"typeFolder": "پوشه",
"noExpiration": "بدون انقضا",
"hasPassword": "بله",
"noPassword": "خیر",
"editShare": "ویرایش هم‌رسانی",
"notifyShare": "آگاه‌سازی کسی",
"copyLink": "رونوشت پیوند",
"removeShare": "حذف هم‌رسانی",
"linkCopied": "پیوند به بُریده‌دان رونوشت شد",
"linkCopyFailed": "رونوشت پیوند ناموفق بود",
"itemUpdated": "تنظیمات هم‌رسانی با موفقیت به‌روزرسانی شد",
"itemRemoved": "هم‌رسانی با موفقیت پاک شد",
"invalidEmail": "لطفا یک نشانی رایانامه معتبر وارد کنید",
"notificationSent": "آگاه‌سازی با موفقیت ارسال شد",
"notificationFailed": "ارسال آگاه‌سازی ناموفق بود",
"shared_backToFiles": "بازگشت به پرونده‌ها",
"shared_pageTitle": "منابع هم‌رسانی شده",
"shared_pageDescription": "مدیریت پرونده‌ها و پوشه‌های هم‌رسانی شده شما",
"shared_filterType": "نوع:",
"shared_filterAll": "همه",
"shared_filterFiles": "پرونده‌ها",
"shared_filterFolders": "پوشه‌ها",
"shared_sortBy": "مرتب‌سازی بر اساس:",
"shared_sortByName": "نام",
"shared_sortByDate": "تاریخ هم‌رسانی",
"shared_sortByExpiration": "تاریخ انقضا",
"shared_search": "جست‌و‌جو",
"shared_colName": "نام",
"shared_colType": "نوع",
"shared_colDateShared": "تاریخ هم‌رسانی",
"shared_colExpiration": "تاریخ انقضا",
"shared_colPermissions": "دسترسی‌ها",
"shared_colPassword": "گذرواژه",
"shared_colActions": "عملیات",
"shared_emptyStateTitle": "هنوز هیچ منبعی هم‌رسانی نشده است",
"shared_emptyStateDesc": "وقتی پرونده‌ها یا پوشه‌ها را هم‌رسانی کنید، اینجا نمایش داده می‌شوند",
"shared_goToFiles": "رفتن به پرونده‌ها",
"shared_typeFile": "پرونده",
"shared_typeFolder": "پوشه",
"shared_noExpiration": "بدون انقضا",
"shared_hasPassword": "بله",
"shared_noPassword": "خیر",
"shared_editShare": "ویرایش هم‌رسانی",
"shared_notifyShare": "آگاه‌سازی کسی",
"shared_copyLink": "رونوشت پیوند",
"shared_removeShare": "حذف هم‌رسانی",
"shared_linkCopied": "پیوند به بُریده‌دان رونوشت شد",
"shared_linkCopyFailed": "رونوشت پیوند ناموفق بود",
"shared_itemUpdated": "تنظیمات هم‌رسانی با موفقیت به‌روزرسانی شد",
"shared_itemRemoved": "هم‌رسانی با موفقیت پاک شد",
"shared_invalidEmail": "لطفا یک نشانی رایانامه معتبر وارد کنید",
"shared_notificationSent": "آگاه‌سازی با موفقیت ارسال شد",
"shared_notificationFailed": "ارسال آگاه‌سازی ناموفق بود"
},
"files": {
"name": "نام",
"type": "نوع",
"size": "اندازه",
"modified": "تاریخ تغییر",
"no_files": "هنوز هیچ پرونده‌ای در این پوشه وجود ندارد",
"empty_hint": "برای شروع، فایل‌ها را آپلود کنید یا پوشه بسازید",
"loading": "در حال بارگذاری فایل‌ها…",
"view_grid": "نمای شبکه‌ای",
"view_list": "نمای فهرستی",
"file_types": {
"document": "سند",
"image": "تصویر",
"video": "ویدیو",
"audio": "صوتی",
"pdf": "PDF",
"text": "متن",
"folder": "پوشه",
"spreadsheet": "صفحه گسترده",
"presentation": "ارائه",
"archive": "بایگانی",
"installer": "نصب‌کننده",
"code": "کد"
},
"owner": "مالک"
},
"dialogs": {
"rename_folder": "تغییر نام پوشه",
"new_name": "نام جدید",
"new_folder_title": "پوشه جدید",
"folder_name": "نام پوشه",
"folder_placeholder": "پوشه من",
"rename_title": "تغییر نام",
"move_file": "انتقال پرونده",
"select_destination": "انتخاب پوشهٔ مقصد",
"root": "ریشه",
"delete_confirmation": "آیا مطمئن هستید که می‌خواهید حذف کنید",
"and_contents": "و همهٔ محتویات آن",
"no_undo": "این عملیات قابل بازگردانی نیست",
"share_file": "هم‌رسانی پرونده",
"share_folder": "هم‌رسانی پوشه",
"existing_shares": "هم‌رسانی موجود",
"share_options": "گزینه‌های هم‌رسانی",
"password": "گذرواژه",
"expiration": "تاریخ انقضا",
"permissions": "دسترسی‌ها",
"generated_link": "پیوند تولید شده",
"notify": "ارسال آگاه‌سازی",
"recipient": "گیرنده",
"message": "پیام",
"confirm_delete": "Move to trash",
"confirm_delete_file": "Are you sure you want to move the file \"{{name}}\" to trash?",
"confirm_delete_folder": "Are you sure you want to move the folder \"{{name}}\" and all its contents to trash?",
"confirm_delete_share": "Delete share link",
"confirm_delete_share_msg": "Are you sure you want to delete this shared link?",
"confirm_empty_trash": "Empty trash",
"confirm_permanent_delete": "Delete permanently",
"confirm_permanent_delete_msg": "Are you sure you want to permanently delete this item? This action cannot be undone.",
"confirm_title": "Confirm action",
"go_to_parent": ".. (parent folder)",
"move_folder": "Move folder",
"no_subfolders": "No subfolders",
"rename_file": "Rename file",
"select_this_folder": "Select this folder",
"move_to_home": "انتقال به پوشه خانگی"
},
"dropzone": {
"drag_files": "پرونده‌ها را اینجا بکشید یا برای انتخاب کلیک کنید",
"drop_files": "پرونده‌ها را رها کنید تا بارگذاری شوند"
},
"permissions": {
"read": "خواندن",
"write": "نوشتن",
"reshare": "هم‌رسانی دوباره"
},
"errors": {
"file_not_found": "پرونده پیدا نشد",
"folder_not_found": "پوشه پیدا نشد",
"delete_error": "خطا در پاک کردن",
"upload_error": "خطا در بارگذاری پرونده",
"rename_error": "خطا در تغییر نام",
"move_error": "خطا در انتقال",
"empty_name": "نام نمی‌تواند خالی باشد",
"name_exists": "پرونده یا پوشه‌ای با این نام قبلا وجود دارد",
"generic_error": "خطایی رخ داده است",
"group_name_invalid": "نام گروه باید با قالب پیشوند ایمیل مطابقت داشته باشد (حروف، ارقام، نقطه، خط تیره، زیرخط؛ 1–64 نویسه).",
"group_cycle": "این عضو باعث ایجاد ارجاع چرخه‌ای بین گروه‌ها می‌شود.",
"group_depth_exceeded": "عمق تودرتو بیش از حداکثر مجاز (8) است.",
"group_virtual_immutable": "گروه «Internal» توسط سامانه مدیریت می‌شود و قابل تغییر نیست.",
"group_not_found": "گروه پیدا نشد.",
"group_name_taken": "گروهی با این نام پیش‌از این وجود دارد."
},
"breadcrumb": {
"home": "صفحه اصلی"
},
"trash": {
"empty_trash": "خالی کردن سطل زباله",
"empty_state": "سطل زباله خالی است",
"original_location": "محل اصلی",
"deleted_date": "تاریخ حذف",
"remaining": "باقی‌مانده",
"actions": "عملیات",
"restore": "بازیابی",
"delete_permanently": "حذف دائمی",
"empty_confirm": "آیا مطمئن هستید که می‌خواهید سطل زباله را خالی کنید؟ این کار همهٔ موارد را به‌طور دائمی حذف خواهد کرد.",
"groupby": {
"remaining_days": "روزهای باقی‌مانده",
"trashed_time": "زمان حذف"
}
},
"daysRemaining": {
"expired": "منقضی شده",
"today": "امروز",
"tomorrow": "فردا",
"inDays": "{{count}} روز"
},
"expiryChip": {
"never": "هرگز منقضی نمی‌شود",
"expired": "منقضی شده",
"today": "امروز منقضی می‌شود",
"tomorrow": "فردا منقضی می‌شود",
"inDays": "در {{count}} روز منقضی می‌شود",
"onDate": "در {{date}} منقضی می‌شود"
},
"auth": {
"login_title": "ورود",
"username": "نام‌کاربری",
"username_placeholder": "نام‌کاربری خود را وارد کنید",
"login_identifier": "نام کاربری یا ایمیل",
"login_identifier_placeholder": "نام کاربری یا ایمیل خود را وارد کنید",
"password": "گذرواژه",
"password_placeholder": "گذرواژه خود را وارد کنید",
"login_button": "ورود",
"no_account": "حساب کاربری ندارید؟",
"register": "نام‌نویسی",
"admin_setup": "اولین بار است؟",
"setup": "راه‌اندازی اولیه مدیریت",
"register_title": "ایجاد حساب کاربری",
"email": "رایانامه",
"email_placeholder": "رایانامه خود را وارد کنید",
"confirm_password": "تأیید گذرواژه",
"confirm_password_placeholder": "گذرواژه خود را تأیید کنید",
"register_button": "ایجاد حساب کاربری",
"have_account": "حساب کاربری دارید؟",
"login": "ورود",
"setup_title": "راه‌اندازی اولیه",
"setup_step1": "مدیر",
"setup_step2": "سیستم",
"setup_step3": "تکمیل",
"admin_username": "نام‌کاربری مدیر",
"admin_email": "رایانامه مدیر",
"admin_password": "گذرواژه مدیر",
"create_admin": "ایجاد مدیر",
"back_to_login": "قبلا راه‌اندازی شده است؟",
"admin_success": "حساب کاربری مدیر با موفقیت ایجاد شد! اکنون می‌توانید وارد شوید.",
"account_success": "حساب کاربری با موفقیت ایجاد شد! اکنون می‌توانید وارد شوید.",
"passwords_mismatch": "گذرواژه‌ها مطابقت ندارند",
"admin_create_error": "خطا در ایجاد حساب کاربری مدیر",
"or": "یا",
"sso_login": "ورود با SSO",
"sso_login_provider": "ورود با {{provider}}",
"magicLinkHint": "رمز عبور ندارید؟ ایمیل خود را وارد کنید تا یک پیوند ورود یک‌بار‌مصرف برایتان ارسال شود.",
"magicLinkEmailLabel": "آدرس ایمیل",
"magicLinkEmailPlaceholder": "you@example.com",
"magicLinkSubmit": "ارسال پیوند ورود",
"magicLinkSent": "اگر برای این ایمیل حسابی وجود داشته باشد، پیوند ورود ارسال شده است. صندوق ورودی خود را بررسی کنید.",
"magicLinkUnavailable": "ورود با ایمیل در این سرور در دسترس نیست.",
"magicLinkNetworkError": "ارتباط با سرور برقرار نشد: {{message}}",
"magicLinkToggle": "No password? Email me a sign-in link",
"passwordsMatch": "Passwords match",
"capsLock": "Caps Lock is on"
},
"storage": {
"title": "فضای ذخیره‌سازی",
"calculating": "در حال محاسبه...",
"used": "{{percentage}}% استفاده شده ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "این نوع پرونده قابل پیش‌نمایش نیست.",
"download_file": "بارگیری پرونده",
"zoom_in": "بزرگ‌نمایی",
"zoom_out": "کوچک‌نمایی",
"zoom_reset": "بازنشانی بزرگ‌نمایی"
},
"language_selector": {
"title": "!خوش آمدید",
"subtitle": "زبان خود را برای ادامه انتخاب کنید",
"continue": "ادامه",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "هنوز هیچ مورد علاقه‌ای وجود ندارد",
"empty_hint": "برای افزودن به موارد علاقه‌مند، پرونده‌ها یا پوشه‌ها را ستاره‌دار کنید",
"add": "افزودن به موارد علاقه‌مند",
"remove": "حذف از موارد علاقه‌مند",
"added_title": "به موارد علاقه‌مند افزوده شد",
"added_msg": "به موارد علاقه‌مند افزوده شد",
"removed_title": "از موارد علاقه‌مند حذف شد",
"removed_msg": "از موارد علاقه‌مند حذف شد"
},
"recent": {
"title": "اخیر",
"clear": "پاک کردن اخیر",
"accessed": "دسترسی یافته",
"empty_state": "هنوز هیچ پروندهٔ اخیر وجود ندارد",
"empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند",
"loadMore": "بارگذاری بیشتر"
},
"batch": {
"one_selected": "۱ مورد انتخاب شده",
"n_selected": "{{count}} مورد انتخاب شده",
"confirm_delete": "آیا مطمئنید که می‌خواهید {{count}} مورد را به سطل زباله منتقل کنید؟",
"move_title": "انتقال {{count}} مورد",
"add_favorites": "افزودن به موارد علاقه‌مند",
"move_copy": "انتقال یا کپی"
},
"admin": {
"page_title": "پنل مدیریت",
"back_to_app": "بازگشت به OxiCloud",
"loading": "در حال بارگذاری…",
"access_denied": "دسترسی ممنوع",
"access_denied_desc": "امتیازات مدیر لازم است.",
"sign_in": "ورود",
"tab_dashboard": "داشبورد",
"tab_users": "کاربران",
"tab_oidc": "SSO / OIDC",
"total_users": "کل کاربران",
"active_users": "کاربران فعال",
"admins": "مدیران",
"version": "نسخه",
"storage_overview": "نمای کلی فضا",
"used": "استفاده شده",
"total_quota": "سهمیه کل",
"usage_pct": "درصد استفاده",
"users_over_80": "کاربران بالای ۸۰٪",
"users_over_quota": "کاربران بالای سهمیه",
"system": "سیستم",
"auth_label": "احراز هویت",
"oidc_label": "OIDC",
"quotas_label": "سهمیه‌ها",
"enabled": "فعال",
"disabled": "غیرفعال",
"active": "فعال",
"off": "خاموش",
"allow_registration": "اجازه ثبت‌نام عمومی",
"registration_warning": "ثبت‌نام عمومی غیرفعال است. فقط مدیران می‌توانند کاربر جدید بسازند.",
"user_management": "مدیریت کاربران",
"create_user": "ایجاد کاربر",
"col_user": "کاربر",
"col_role": "نقش",
"col_auth": "احراز هویت",
"col_status": "وضعیت",
"col_storage": "فضا",
"col_last_login": "آخرین ورود",
"col_actions": "عملیات",
"loading_users": "در حال بارگذاری…",
"failed_load_users": "خطا در بارگذاری",
"no_users_found": "کاربری یافت نشد",
"showing_users": "نمایش {{from}}-{{to}} از {{total}}",
"prev": "قبلی",
"next": "بعدی",
"inactive": "غیرفعال",
"you_badge": "(شما)",
"local": "محلی",
"never": "هرگز",
"just_now": "همین الان",
"minutes_ago": "{{n}} دقیقه پیش",
"hours_ago": "{{n}} ساعت پیش",
"days_ago": "{{n}} روز پیش",
"edit_quota_title": "ویرایش سهمیه",
"reset_password_title": "بازنشانی رمز",
"toggle_role_title": "تغییر نقش",
"deactivate_title": "غیرفعال کردن",
"activate_title": "فعال کردن",
"delete_title": "حذف",
"sso_title": "ورود یکپارچه (OIDC / SSO)",
"enable_sso": "فعال‌سازی SSO",
"provider_name": "نام ارائه‌دهنده",
"issuer_url": "آدرس صادرکننده",
"issuer_url_hint": "آدرس صادرکننده OpenID Connect",
"auto_discover": "کشف خودکار",
"discovering": "در حال کشف…",
"client_id": "شناسه مشتری",
"client_secret": "رمز مشتری",
"client_secret_placeholder": "خالی بگذارید تا مقدار فعلی حفظ شود",
"secret_configured": "رمز مشتری قبلاً پیکربندی شده",
"callback_url": "آدرس بازگشت",
"callback_url_hint": "(در IdP ثبت کنید)",
"advanced_settings": "تنظیمات پیشرفته",
"scopes": "محدوده‌ها",
"auto_provision": "تامین خودکار کاربران",
"admin_groups": "گروه‌های مدیر",
"admin_groups_hint": "نام گروه‌های OIDC جدا شده با کاما",
"disable_password": "غیرفعال‌سازی ورود با رمز (فقط OIDC)",
"password_warning": "تمام ورودهای رمزی متوقف می‌شود!",
"test_btn": "آزمایش",
"save_btn": "ذخیره",
"saving": "در حال ذخیره…",
"settings_saved": "تنظیمات ذخیره شد — OIDC اکنون {{status}}",
"quota_modal_title": "به‌روزرسانی سهمیه",
"quota_user_label": "کاربر:",
"new_quota": "سهمیه جدید",
"quota_unlimited_hint": "۰ برای نامحدود",
"cancel": "انصراف",
"create_user_title": "ایجاد کاربر جدید",
"username_label": "نام کاربری",
"username_placeholder": "نام‌کاربری",
"username_hint": "۳ تا ۳۲ کاراکتر",
"password_label": "رمز عبور",
"password_placeholder": "حداقل ۸ کاراکتر",
"email_label": "ایمیل",
"email_optional": "(اختیاری)",
"email_placeholder": "user@example.com (خودکار اگر خالی)",
"role_label": "نقش",
"role_user": "کاربر",
"role_admin": "مدیر",
"quota_label": "سهمیه",
"creating": "در حال ایجاد…",
"reset_pw_title": "بازنشانی رمز عبور",
"new_password_label": "رمز عبور جدید",
"resetting": "در حال بازنشانی…",
"reset_btn": "بازنشانی",
"confirm_role_change": "نقش به {{role}} تغییر یابد؟",
"confirm_deactivate": "آیا از غیرفعال‌سازی این کاربر مطمئنید؟",
"confirm_activate": "آیا از فعال‌سازی این کاربر مطمئنید؟",
"confirm_delete_user": "کاربر «{{name}}» حذف شود؟ قابل بازگشت نیست!",
"confirm_action": "تأیید عملیات",
"confirm_yes": "تأیید",
"confirm_no": "انصراف",
"error_username_short": "نام کاربری حداقل ۳ کاراکتر",
"error_password_short": "رمز عبور حداقل ۸ کاراکتر",
"error_generic": "خطا",
"error_network": "خطای شبکه: {{message}}",
"error_create_user": "خطا در ایجاد کاربر",
"tab_storage": "فضای ذخیره‌سازی",
"storage_title": "تنظیمات فضای ذخیره‌سازی",
"storage_current_backend": "بک‌اند فعلی",
"storage_total_blobs": "مجموع بلوب‌ها",
"storage_total_size": "حجم کل",
"storage_dedup_ratio": "نسبت حذف تکراری",
"storage_backend": "بک‌اند",
"storage_local": "محلی",
"storage_s3": "سازگار با S3",
"storage_provider_preset": "پیش‌تنظیم ارائه‌دهنده",
"storage_preset_custom": "سفارشی",
"storage_endpoint_url": "آدرس نقطه پایانی",
"storage_endpoint_hint": "برای AWS S3 خالی بگذارید",
"storage_bucket": "باکت",
"storage_region": "منطقه",
"storage_access_key": "کلید دسترسی",
"storage_secret_key": "کلید مخفی",
"storage_secret_configured": "کلید تنظیم شد",
"storage_key_placeholder": "کلید جدید وارد کنید",
"storage_path_style": "اجبار سبک مسیر",
"storage_path_style_hint": "برای MinIO و برخی سرویس‌های سازگار با S3 لازم است",
"storage_test_connection": "آزمایش اتصال",
"storage_test_success": "اتصال موفق",
"storage_test_failure": "اتصال ناموفق",
"storage_save": "ذخیره تنظیمات",
"storage_saved": "تنظیمات ذخیره شد",
"storage_migration": "انتقال داده",
"storage_migration_coming_soon": "ابزارهای انتقال به زودی",
"migration_status_label": "وضعیت انتقال",
"migration_start": "شروع انتقال",
"migration_pause": "توقف",
"migration_resume": "ادامه",
"migration_verify": "تأیید",
"migration_complete": "تکمیل",
"migration_started": "انتقال شروع شد",
"migration_paused_msg": "انتقال متوقف شد",
"migration_resumed_msg": "انتقال ادامه یافت",
"migration_completed_msg": "انتقال با موفقیت تکمیل شد",
"migration_verifying": "در حال تأیید...",
"migration_verify_passed": "تأیید موفق",
"migration_verify_failed": "تأیید ناموفق",
"migration_failed_blobs": "بلوب‌های ناموفق",
"testing": "در حال آزمایش...",
"smtp_disabled": "غیرفعال (میزبان تنظیم نشده)",
"smtp_enabled": "فعال",
"smtp_enabled_label": "وضعیت",
"smtp_intro": "SMTP فقط از طریق متغیرهای محیطی (OXICLOUD_SMTP_*) پیکربندی می‌شود. مقادیر زیر از سرور در حال اجرا خوانده می‌شوند — برای تغییر آن‌ها، محیط را ویرایش کرده و OxiCloud را راه‌اندازی مجدد کنید.",
"smtp_not_configured": "SMTP روی این سرور پیکربندی نشده است.",
"smtp_send_failed": "ارسال ناموفق.",
"smtp_send_test": "ارسال ایمیل آزمایشی",
"smtp_sending": "در حال ارسال…",
"smtp_sent": "ایمیل آزمایشی ارسال شد.",
"smtp_server_code": "پاسخ سرور",
"smtp_test_intro": "یک پیام تشخیصی از پیش تعریف‌شده را به گیرنده زیر ارسال می‌کند و پاسخ سرور SMTP را گزارش می‌دهد تا بتوانید آن را با گزارش‌های ریلی خود مطابقت دهید.",
"smtp_test_missing_to": "آدرس گیرنده را وارد کنید.",
"smtp_test_title": "ارسال ایمیل آزمایشی",
"smtp_test_to": "آدرس گیرنده",
"smtp_title": "ایمیل خروجی (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "پروفایل",
"back_to_app": "بازگشت به OxiCloud",
"loading": "در حال بارگذاری…",
"not_authenticated": "احراز هویت نشده",
"not_authenticated_desc": "برای مشاهده پروفایل وارد شوید.",
"sign_in": "ورود",
"role_admin": "مدیر",
"role_user": "کاربر",
"account_details": "جزئیات حساب",
"username": "نام کاربری",
"email": "ایمیل",
"role": "نقش",
"last_login": "آخرین ورود",
"storage": "فضای ذخیره‌سازی",
"used": "استفاده شده",
"quota": "سهمیه",
"usage": "مصرف",
"unlimited": "نامحدود",
"app_passwords": "رمزهای برنامه",
"app_pw_desc": "رمزهایی برای کلاینت‌های WebDAV، CalDAV و CardDAV ایجاد کنید. هر رمز فقط یک بار نمایش داده می‌شود.",
"app_pw_label_placeholder": "برچسب (مثلاً Thunderbird، macOS)",
"generate": "ایجاد",
"generating": "در حال ایجاد…",
"new_password_for": "رمز جدید برای",
"copy_warning": "این رمز را اکنون کپی کنید. دوباره قابل مشاهده نیست.",
"copy_to_clipboard": "کپی به کلیپ‌بورد",
"col_label": "برچسب",
"col_created": "ایجاد شده",
"col_last_used": "آخرین استفاده",
"col_status": "وضعیت",
"active": "فعال",
"revoked": "ابطال شده",
"revoke_title": "ابطال",
"no_app_passwords": "هنوز رمز برنامه‌ای وجود ندارد.",
"client_sessions": "نشست‌های کلاینت",
"client_sessions_desc": "هنگام اتصال کلاینت سازگار با Nextcloud به صورت خودکار ایجاد می‌شود.",
"col_client": "کلاینت",
"never": "هرگز",
"just_now": "همین الان",
"minutes_ago": "{{n}} دقیقه پیش",
"hours_ago": "{{n}} ساعت پیش",
"days_ago": "{{n}} روز پیش",
"edit_profile": "ویرایش نمایه",
"edit_oidc_managed": "برای تغییر اطلاعات خود (نام، نام خانوادگی، عکس نمایه، …)، لطفاً آن‌ها را در ارائه‌دهنده هویت خود به‌روز کنید. تغییرات شما در ورود بعدی ظاهر خواهد شد.",
"username_claim_hint": "۲ تا ۶۴ کاراکتر، حروف / ارقام / نقطه / خط تیره / زیرخط. پس از انتخاب، نام کاربری قابل تغییر نیست (کلاینت‌های DAV/NextCloud به آن وابسته‌اند).",
"username_already_claimed": "نام کاربری تنظیم شده و قابل تغییر نیست (کلاینت‌های DAV/NextCloud به آن وابسته‌اند).",
"given_name": "نام",
"family_name": "نام خانوادگی",
"notify_on_share": "وقتی کسی با من چیزی به اشتراک می‌گذارد، به من ایمیل بزن",
"notify_on_share_hint": "وقتی تیک‌خورده نباشد، اشتراک‌گذاری‌ها همچنان در حساب شما نمایش داده می‌شوند — فقط ایمیلی درباره آنها دریافت نخواهید کرد.",
"save_profile": "ذخیره تغییرات",
"profile_saved": "نمایه به‌روز شد",
"profile_no_changes": "تغییری برای ذخیره وجود ندارد.",
"profile_save_failed": "ذخیره ناموفق بود",
"username_taken_error": "این نام کاربری قبلاً گرفته شده است.",
"username_immutable_error": "نام کاربری شما قبلاً تنظیم شده و در اینجا قابل تغییر نیست. در صورت نیاز به تغییر نام، با مدیر تماس بگیرید.",
"change_password": "تغییر رمز عبور",
"current_password": "رمز فعلی",
"new_password": "رمز جدید",
"min_8_chars": "حداقل ۸ کاراکتر",
"confirm_password": "تأیید رمز جدید",
"update_password": "به‌روزرسانی رمز",
"updating": "در حال به‌روزرسانی…",
"password_updated": "رمز عبور با موفقیت به‌روز شد",
"passwords_no_match": "رمزها مطابقت ندارند",
"password_too_short": "رمز باید حداقل ۸ کاراکتر باشد",
"password_change_failed": "تغییر رمز ناموفق بود",
"error_network": "خطای شبکه: {{message}}",
"error_label_required": "لطفاً برچسب وارد کنید",
"error_create_pw": "ایجاد رمز ناموفق بود",
"confirm_revoke": "رمز «{{label}}» ابطال شود؟ کلاینت‌ها از کار می‌افتند.",
"error_revoke": "ابطال ناموفق بود",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"notifications": {
"file_renamed": "فایل تغییر نام داد",
"file_renamed_to": "فایل به \"{{name}}\" تغییر نام داد",
"folder_renamed": "پوشه تغییر نام داد",
"folder_renamed_to": "پوشه به \"{{name}}\" تغییر نام داد",
"file_uploaded": "فایل آپلود شد",
"file_deleted": "فایل به زباله‌دان منتقل شد",
"folder_deleted": "پوشه به زباله‌دان منتقل شد",
"item_deleted_permanently": "آیتم برای همیشه حذف شد",
"trash_emptied": "زباله‌دان خالی شد",
"title": "اعلان‌ها",
"empty": "بدون اعلان",
"link_created": "پیوند ایجاد شد",
"share_success": "پیوند اشتراک‌گذاری با موفقیت ایجاد شد",
"upload_files_section_title": "بارگذاری اینجا در دسترس نیست",
"upload_files_section_body": "برای بارگذاری فایل‌ها به بخش فایل‌ها بروید"
},
"upload": {
"uploading": "در حال آپلود...",
"files": "فایل‌ها",
"complete": "{{count}} / {{total}} آپلود شد"
},
"storage_quota_exceeded": "سهمیه فضای ذخیره‌سازی تجاوز کرده است",
"sharedwithme": {
"pageTitle": "به اشتراک‌گذاشته شده با من",
"pageDescription": "فایل‌ها و پوشه‌هایی که کاربران دیگر با شما به اشتراک گذاشته‌اند",
"emptyStateTitle": "هنوز چیزی با شما به اشتراک گذاشته نشده",
"emptyStateDesc": "مواردی که کاربران دیگر با شما به اشتراک می‌گذارند اینجا نمایش داده می‌شوند",
"loadMore": "بارگذاری بیشتر",
"sharedBy": "به اشتراک‌گذاشته توسط",
"colName": "نام",
"colType": "نوع",
"colSharedBy": "به اشتراک‌گذاشته توسط",
"colDate": "تاریخ اشتراک‌گذاری",
"colPermissions": "مجوزها"
},
"groupby": {
"none": "هیچ",
"title": "گروه‌بندی بر اساس",
"owner": "مالک",
"shareDate": "تاریخ اشتراک",
"type": "نوع",
"type.folders": "پوشه‌ها",
"accessedAt": "تاریخ دسترسی",
"modifiedAt": "تاریخ تغییر",
"createdAt": "تاریخ ایجاد",
"size": "اندازه",
"favoriteDate": "تاریخ مورد علاقه",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "جدید"
},
"dateBucket": {
"today": "امروز",
"last7days": "۷ روز گذشته",
"last30days": "۳۰ روز گذشته"
},
"groups": {
"title": "مدیریت گروه‌ها",
"create_button": "ایجاد گروه",
"create_dialog_title": "گروه جدید",
"edit_dialog_title": "تغییر نام گروه",
"name_label": "نام",
"name_placeholder": "engineering",
"description_label": "توضیحات (اختیاری)",
"members_section": "اعضا",
"add_member_placeholder": "افزودن کاربر یا گروه…",
"no_members": "هنوز عضوی وجود ندارد.",
"remove_member": "حذف",
"delete_group": "حذف گروه",
"delete_confirm": "گروه «{name}» حذف شود؟ مجوزهای مرتبط با این گروه باطل خواهند شد.",
"empty_state": "هنوز گروهی وجود ندارد.",
"load_more": "بارگیری بیشتر",
"back_to_list": "بازگشت",
"loading": "در حال بارگذاری…",
"virtual_badge": "سامانه",
"member_count_zero": "بدون عضو",
"member_count_one": "۱ عضو",
"member_count_other": "{count} عضو",
"delete_confirm_label": "نام گروه را برای تأیید وارد کنید:",
"delete_confirm_mismatch": "نام گروه را دقیقاً برای تأیید وارد کنید.",
"virtual_internal_name": "داخلی",
"members_loading": "در حال بارگیری اعضا…",
"members_empty": "بدون عضو",
"virtual_internal_explanation": "هر کاربر داخلی روی این سرور"
},
"myshares": {
"copyLink": "کپی پیوند",
"deleteLink": "حذف پیوند",
"notifyByEmail": "اطلاع‌رسانی از طریق ایمیل",
"notifyFailed": "ارسال اعلان ممکن نشد.",
"notifyGroupMembers": "اطلاع‌رسانی به اعضای گروه",
"notifyRateLimited": "اعلان‌های زیادی برای این گیرنده — بعداً دوباره تلاش کنید.",
"removeAccess": "حذف دسترسی",
"resendInvitation": "ارسال مجدد ایمیل دعوت"
},
"sort": {
"asc": "ascending",
"desc": "descending"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "Ce lien de connexion n'est plus valide",
"expired_body": "Le lien a peut-être expiré ou a déjà été utilisé. Nous pouvons vous en envoyer un nouveau — il arrivera dans votre boîte de réception dans quelques secondes.",
"resend_to": "Envoyer un nouveau lien à {{email}}",
"generic_unavailable": "Ce lien de connexion n'est plus valide. Il a peut-être déjà été utilisé ou a expiré. Demandez un nouveau lien depuis la page de connexion.",
"service_unavailable": "La connexion par lien magique n'est pas activée sur ce serveur.",
"internal_error": "Une erreur s'est produite lors de votre connexion. Veuillez réessayer.",
"resend_failure": "Une erreur s'est produite lors de l'envoi du lien. Veuillez réessayer.",
"cross_browser_title": "Continuer la connexion sur cet appareil ?",
"cross_browser_body": "Vous avez ouvert ce lien de connexion dans un navigateur ou un appareil différent de celui où vous l'avez demandé.",
"cross_browser_warning": "Si vous avez demandé ce lien, vous pouvez continuer en toute sécurité. Sinon, fermez cette page — cliquer sur Continuer connecterait quelqu'un d'autre à votre compte.",
"cross_browser_continue": "Continuer et se connecter",
"resend_confirmation_title": "Vérifiez votre boîte de réception",
"resend_confirmation_body": "Si le lien de connexion correspondait à un compte actif, un nouveau lien vient d'être envoyé. Veuillez vérifier votre boîte de réception.",
"return_link": "Retour à OxiCloud"
},
"email": {
"invitation": {
"subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud",
"body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez-le en cliquant sur le lien ci-dessous :\n{{link}}\n\nLe lien est à usage unique et expire dans {{ttl_hours}} heures.\nSi vous n'attendiez pas cette invitation, vous pouvez ignorer ce message.\n\n— OxiCloud"
},
"login": {
"subject": "Connexion à OxiCloud",
"body": "Bonjour,\n\nUtilisez le lien ci-dessous pour vous connecter à OxiCloud. Le lien est à usage unique et expire dans {{ttl_minutes}} minutes. Ouvrez-le sur le même appareil que celui où vous l'avez demandé.\n\n{{link}}\n\nSi vous n'avez pas demandé ce lien de connexion, vous pouvez ignorer ce message — aucune action supplémentaire n'est nécessaire.\n\n— OxiCloud"
},
"kind_file": "fichier",
"kind_folder": "dossier",
"english_fallback_divider": "--- Version anglaise ci-dessous ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} a partagé un {{kind}} avec vous sur OxiCloud",
"body": "{{inviter_full}} a partagé un {{kind}} avec vous sur OxiCloud.\n\nOuvrez OxiCloud pour voir votre nouveau partage :\n{{login_link}}\n\nVous avez peut-être d'autres nouveaux partages de {{inviter}} — connectez-vous pour voir tous vos éléments partagés.\n\n— OxiCloud\n\nVous recevez ce message parce que vous avez un compte OxiCloud et que la préférence de notification de partage est activée. Vous pouvez la désactiver dans votre profil (M'avertir par e-mail quand quelqu'un partage avec moi)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "Système de stockage cloud minimaliste"
},
"nav": {
"files": "Fichiers",
"shared": "Partages",
"recent": "Récents",
"favorites": "Favoris",
"photos": "Photos",
"music": "Musique",
"trash": "Corbeille",
"sharedwithme": "Partages avec moi"
},
"photos": {
"empty_state": "Pas encore de photos",
"empty_hint": "Téléchargez des images ou des vidéos pour les voir ici",
"items_selected": "sélectionnés",
"view_daily": "Jour",
"view_monthly": "Mois",
"view_yearly": "Année"
},
"music": {
"create_playlist": "Créer une Playlist",
"playlists": "Playlists",
"no_playlists": "Aucune playlist",
"select_playlist": "Sélectionnez une playlist",
"select_hint": "Choisissez une playlist dans la barre latérale ou créez-en une nouvelle",
"add_tracks": "Ajouter des Pistes",
"no_tracks": "Aucune piste dans cette playlist",
"unknown_artist": "Artiste Inconnu",
"unknown_title": "Inconnu",
"confirm_delete": "Supprimer cette playlist ?",
"playlist_name": "Nom de la playlist",
"create": "Créer",
"delete": "Supprimer",
"share": "Partager",
"edit": "Modifier",
"play_all": "Tout Lire",
"shuffle": "Aléatoire",
"repeat": "Répéter",
"repeat_one": "Répéter Une",
"queue": "File d'attente",
"queue_empty": "File d'attente vide",
"not_playing": "Pas en lecture",
"play": "Lecture",
"pause": "Pause",
"previous": "Précédent",
"next": "Suivant",
"volume": "Volume",
"mute": "Muet",
"unmute": "Activer le son",
"title": "Titre",
"artist": "Artiste",
"album": "Album",
"tracks": "pistes",
"add": "Ajouter",
"added": "Ajouté !",
"added_to_playlist": "ajouté à la playlist",
"add_to_playlist": "Ajouter à la playlist",
"load_error": "Erreur de chargement des playlists",
"add_error": "Impossible d'ajouter les pistes",
"no_playlists_yet": "Pas encore de playlists. Créez-en une d'abord !",
"selected_files": "Sélectionnés :",
"error": "Erreur",
"search_audio": "Rechercher des fichiers audio…",
"no_audio_files": "Aucun fichier audio trouvé",
"selected": "sélectionnés",
"loading": "Chargement…",
"search_error": "Impossible de charger les fichiers audio",
"adding": "Ajout en cours…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "Rechercher des fichiers...",
"new_folder": "Nouveau dossier",
"upload": "Téléverser",
"upload_files": "Téléverser des fichiers",
"upload_folder": "Téléverser un dossier",
"upload.uploading": "Envoi en cours...",
"upload.complete": "{count} / {total} envoyés",
"upload.files": "fichiers",
"rename": "Renommer",
"move": "Déplacer vers...",
"move_to": "Déplacer vers",
"delete": "Supprimer",
"download": "Télécharger",
"view": "Afficher",
"cancel": "Annuler",
"confirm": "Confirmer",
"share": "Partager",
"favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris",
"copy": "Copier",
"notify": "Notifier",
"send": "Envoyer",
"clear_recent": "Effacer les récents",
"logout": "Se déconnecter",
"create": "Créer",
"search_btn": "Rechercher",
"close": "Fermer",
"delete_permanently": "Supprimer définitivement",
"empty_trash": "Vider la corbeille",
"open_parent_folder": "Aller au dossier parent",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "Apparence",
"about": "À propos d'OxiCloud",
"about_description": "Plateforme de stockage cloud construite avec Rust et Architecture Propre. Rapide, sécurisée et privée.",
"admin_panel": "Panneau d'administration",
"profile": "Mon profil",
"role_user": "Utilisateur",
"theme": {
"light": "Clair",
"dark": "Sombre",
"auto": "Comme le système"
},
"manage_groups": "Gérer les groupes"
},
"share": {
"dialogTitle": "Lien de partage",
"linkLabel": "Lien partagé :",
"copyLink": "Copier",
"permissions": "Permissions :",
"permissionRead": "Lecture",
"permissionWrite": "Écriture",
"permissionReshare": "Repartager",
"password": "Protection par mot de passe :",
"generatePassword": "Générer",
"expiration": "Date d'expiration :",
"update": "Mettre à jour le partage",
"remove": "Supprimer le partage",
"notifyTitle": "Envoyer une notification",
"notifyEmailLabel": "Adresse e-mail :",
"notifyMessageLabel": "Message (facultatif) :",
"notifySend": "Envoyer la notification",
"shareWithOthers": "Partager avec d'autres",
"sharePublicly": "Partager publiquement",
"shareSettings": "Paramètres de partage",
"shareCopied": "Lien copié dans le presse-papiers",
"shareCreated": "Lien de partage créé avec succès",
"shareUpdated": "Paramètres de partage mis à jour",
"shareRemoved": "Partage supprimé avec succès",
"inviteByEmail": "Inviter par e-mail — une invitation sera envoyée",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "Lien de partage",
"share_linkLabel": "Lien partagé :",
"share_copyLink": "Copier",
"share_permissions": "Permissions :",
"share_permissionRead": "Lecture",
"share_permissionWrite": "Écriture",
"share_permissionReshare": "Repartager",
"share_password": "Protection par mot de passe :",
"share_generatePassword": "Générer",
"share_expiration": "Date d'expiration :",
"share_update": "Mettre à jour le partage",
"share_remove": "Supprimer le partage",
"share_notifyTitle": "Envoyer une notification",
"share_notifyEmailLabel": "Adresse e-mail :",
"share_notifyMessageLabel": "Message (facultatif) :",
"share_notifySend": "Envoyer la notification",
"shared": {
"backToFiles": "Retour aux fichiers",
"pageTitle": "Ressources partagées",
"pageDescription": "Gérez vos fichiers et dossiers partagés",
"filterType": "Type :",
"filterAll": "Tous",
"filterFiles": "Fichiers",
"filterFolders": "Dossiers",
"sortBy": "Trier par :",
"sortByName": "Nom",
"sortByDate": "Date de partage",
"sortByExpiration": "Expiration",
"search": "Rechercher",
"colName": "Nom",
"colType": "Type",
"colDateShared": "Date de partage",
"colExpiration": "Expiration",
"colPermissions": "Permissions",
"colPassword": "Mot de passe",
"colActions": "Actions",
"emptyStateTitle": "Aucune ressource partagée",
"emptyStateDesc": "Lorsque vous partagerez des fichiers ou dossiers, ils apparaîtront ici",
"goToFiles": "Aller aux fichiers",
"typeFile": "Fichier",
"typeFolder": "Dossier",
"noExpiration": "Sans expiration",
"hasPassword": "Oui",
"noPassword": "Non",
"editShare": "Modifier le partage",
"notifyShare": "Notifier quelqu'un",
"copyLink": "Copier le lien",
"removeShare": "Supprimer le partage",
"linkCopied": "Lien copié dans le presse-papiers !",
"linkCopyFailed": "Erreur lors de la copie du lien",
"itemUpdated": "Paramètres de partage mis à jour",
"itemRemoved": "Partage supprimé avec succès",
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"notificationSent": "Notification envoyée avec succès",
"notificationFailed": "Erreur lors de l'envoi de la notification",
"shared_backToFiles": "Retour aux fichiers",
"shared_pageTitle": "Ressources partagées",
"shared_pageDescription": "Gérez vos fichiers et dossiers partagés",
"shared_filterType": "Type :",
"shared_filterAll": "Tous",
"shared_filterFiles": "Fichiers",
"shared_filterFolders": "Dossiers",
"shared_sortBy": "Trier par :",
"shared_sortByName": "Nom",
"shared_sortByDate": "Date de partage",
"shared_sortByExpiration": "Expiration",
"shared_search": "Rechercher",
"shared_colName": "Nom",
"shared_colType": "Type",
"shared_colDateShared": "Date de partage",
"shared_colExpiration": "Expiration",
"shared_colPermissions": "Permissions",
"shared_colPassword": "Mot de passe",
"shared_colActions": "Actions",
"shared_emptyStateTitle": "Aucune ressource partagée",
"shared_emptyStateDesc": "Lorsque vous partagerez des fichiers ou dossiers, ils apparaîtront ici",
"shared_goToFiles": "Aller aux fichiers",
"shared_typeFile": "Fichier",
"shared_typeFolder": "Dossier",
"shared_noExpiration": "Sans expiration",
"shared_hasPassword": "Oui",
"shared_noPassword": "Non",
"shared_editShare": "Modifier le partage",
"shared_notifyShare": "Notifier quelqu'un",
"shared_copyLink": "Copier le lien",
"shared_removeShare": "Supprimer le partage",
"shared_linkCopied": "Lien copié dans le presse-papiers !",
"shared_linkCopyFailed": "Erreur lors de la copie du lien",
"shared_itemUpdated": "Paramètres de partage mis à jour",
"shared_itemRemoved": "Partage supprimé avec succès",
"shared_invalidEmail": "Veuillez entrer une adresse e-mail valide",
"shared_notificationSent": "Notification envoyée avec succès",
"shared_notificationFailed": "Erreur lors de l'envoi de la notification"
},
"files": {
"name": "Nom",
"type": "Type",
"size": "Taille",
"modified": "Modifié",
"no_files": "Aucun fichier dans ce dossier",
"empty_hint": "Téléversez des fichiers ou créez des dossiers pour commencer",
"loading": "Chargement des fichiers…",
"view_grid": "Vue en grille",
"view_list": "Vue en liste",
"file_types": {
"document": "Document",
"image": "Image",
"video": "Vidéo",
"audio": "Audio",
"pdf": "PDF",
"text": "Texte",
"folder": "Dossier",
"spreadsheet": "Tableur",
"presentation": "Présentation",
"archive": "Archive",
"installer": "Installateur",
"code": "Code"
},
"owner": "Propriétaire"
},
"dialogs": {
"rename_folder": "Renommer le dossier",
"rename_file": "Renommer le fichier",
"new_name": "Nouveau nom",
"new_folder_title": "Nouveau dossier",
"folder_name": "Nom du dossier",
"folder_placeholder": "Mon dossier",
"rename_title": "Renommer",
"move_file": "Déplacer le fichier",
"move_folder": "Déplacer le dossier",
"select_destination": "Sélectionnez le dossier de destination :",
"root": "Racine",
"delete_confirmation": "Êtes-vous sûr de vouloir supprimer",
"and_contents": "et tout son contenu",
"no_undo": "Cette action est irréversible",
"confirm_title": "Confirmer l'action",
"confirm_delete": "Déplacer vers la corbeille",
"confirm_delete_file": "Êtes-vous sûr de vouloir déplacer le fichier « {{name}} » vers la corbeille ?",
"confirm_delete_folder": "Êtes-vous sûr de vouloir déplacer le dossier « {{name}} » et tout son contenu vers la corbeille ?",
"confirm_permanent_delete": "Supprimer définitivement",
"confirm_permanent_delete_msg": "Êtes-vous sûr de vouloir supprimer définitivement cet élément ? Cette action est irréversible.",
"confirm_empty_trash": "Vider la corbeille",
"confirm_delete_share": "Supprimer le lien de partage",
"confirm_delete_share_msg": "Êtes-vous sûr de vouloir supprimer ce lien de partage ?",
"share_file": "Partager le fichier",
"share_folder": "Partager le dossier",
"existing_shares": "Partages existants",
"share_options": "Options de partage",
"password": "Mot de passe",
"expiration": "Expiration",
"permissions": "Permissions",
"generated_link": "Lien généré",
"notify": "Envoyer une notification",
"recipient": "Destinataire",
"message": "Message",
"go_to_parent": ".. (parent folder)",
"no_subfolders": "No subfolders",
"select_this_folder": "Select this folder",
"move_to_home": "Déplacer vers le dossier personnel"
},
"dropzone": {
"drag_files": "Glissez des fichiers ici ou cliquez pour sélectionner",
"drop_files": "Déposez les fichiers pour téléverser"
},
"permissions": {
"read": "Lecture",
"write": "Écriture",
"reshare": "Repartager"
},
"errors": {
"file_not_found": "Fichier introuvable",
"folder_not_found": "Dossier introuvable",
"delete_error": "Erreur lors de la suppression",
"upload_error": "Erreur lors du téléversement",
"rename_error": "Erreur lors du renommage",
"move_error": "Erreur lors du déplacement",
"empty_name": "Le nom ne peut pas être vide",
"name_exists": "Un fichier ou dossier portant ce nom existe déjà",
"generic_error": "Une erreur est survenue",
"group_name_invalid": "Le nom du groupe doit respecter le format préfixe d'email (lettres, chiffres, point, tiret, souligné ; 1–64 caractères).",
"group_cycle": "Ce membre créerait une référence circulaire entre groupes.",
"group_depth_exceeded": "Cette profondeur d'imbrication dépasse le maximum autorisé (8).",
"group_virtual_immutable": "Le groupe « Internal » est géré par le système et ne peut pas être modifié.",
"group_not_found": "Groupe introuvable.",
"group_name_taken": "Un groupe portant ce nom existe déjà."
},
"breadcrumb": {
"home": "Accueil"
},
"trash": {
"empty_trash": "Vider la corbeille",
"empty_state": "La corbeille est vide",
"original_location": "Emplacement d'origine",
"deleted_date": "Date de suppression",
"remaining": "Restant",
"actions": "Actions",
"restore": "Restaurer",
"delete_permanently": "Supprimer définitivement",
"empty_confirm": "Êtes-vous sûr de vouloir vider la corbeille ? Tous les éléments seront définitivement supprimés.",
"groupby": {
"remaining_days": "Jours restants",
"trashed_time": "Date de suppression"
}
},
"daysRemaining": {
"expired": "Expiré",
"today": "Aujourd'hui",
"tomorrow": "Demain",
"inDays": "{{count}} jours"
},
"expiryChip": {
"never": "N'expire jamais",
"expired": "Expiré",
"today": "Expire aujourd'hui",
"tomorrow": "Expire demain",
"inDays": "Expire dans {{count}} jours",
"onDate": "Expire le {{date}}"
},
"auth": {
"login_title": "Se connecter",
"username": "Nom d'utilisateur",
"username_placeholder": "Entrez votre nom d'utilisateur",
"login_identifier": "Nom d'utilisateur ou e-mail",
"login_identifier_placeholder": "Saisissez votre nom d'utilisateur ou e-mail",
"password": "Mot de passe",
"password_placeholder": "Entrez votre mot de passe",
"login_button": "Se connecter",
"no_account": "Vous n'avez pas de compte ?",
"register": "S'inscrire",
"admin_setup": "Première fois ?",
"setup": "Configurer l'administrateur",
"register_title": "Créer un compte",
"email": "E-mail",
"email_placeholder": "Entrez votre e-mail",
"confirm_password": "Confirmer le mot de passe",
"confirm_password_placeholder": "Confirmez votre mot de passe",
"register_button": "Créer un compte",
"have_account": "Vous avez déjà un compte ?",
"login": "Se connecter",
"setup_title": "Configuration initiale",
"setup_step1": "Admin",
"setup_step2": "Système",
"setup_step3": "Terminé",
"admin_username": "Nom d'utilisateur administrateur",
"admin_email": "E-mail administrateur",
"admin_password": "Mot de passe administrateur",
"create_admin": "Créer l'administrateur",
"back_to_login": "Déjà configuré ?",
"admin_success": "Compte administrateur créé avec succès ! Vous pouvez maintenant vous connecter.",
"account_success": "Compte créé avec succès ! Vous pouvez maintenant vous connecter.",
"passwords_mismatch": "Les mots de passe ne correspondent pas",
"admin_create_error": "Erreur lors de la création du compte administrateur",
"or": "ou",
"sso_login": "Se connecter avec SSO",
"sso_login_provider": "Se connecter avec {{provider}}",
"magicLinkHint": "Pas de mot de passe ? Saisissez votre adresse e-mail et nous vous enverrons un lien de connexion à usage unique.",
"magicLinkEmailLabel": "Adresse e-mail",
"magicLinkEmailPlaceholder": "vous@exemple.com",
"magicLinkSubmit": "Envoyer le lien de connexion",
"magicLinkSent": "Si un compte existe pour cette adresse, un lien de connexion vient d'être envoyé. Consultez votre boîte de réception.",
"magicLinkUnavailable": "La connexion par e-mail n'est pas disponible sur ce serveur.",
"magicLinkNetworkError": "Impossible de joindre le serveur : {{message}}",
"magicLinkToggle": "Pas de mot de passe ? Recevez un lien par e-mail",
"passwordsMatch": "Les mots de passe correspondent",
"capsLock": "Verr. Maj activé"
},
"storage": {
"title": "Stockage",
"calculating": "Calcul en cours...",
"used": "{{percentage}}% utilisé ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "Ce type de fichier ne peut pas être prévisualisé.",
"download_file": "Télécharger le fichier",
"zoom_in": "Zoom avant",
"zoom_out": "Zoom arrière",
"zoom_reset": "Réinitialiser le zoom"
},
"language_selector": {
"title": "Bienvenue !",
"subtitle": "Sélectionnez votre langue pour continuer",
"continue": "Continuer",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"ar": "العربية",
"hi": "हिन्दी",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "Aucun favori pour le moment",
"empty_hint": "Marquez des fichiers ou dossiers avec une étoile pour les ajouter à vos favoris",
"add": "Ajouter aux favoris",
"remove": "Retirer des favoris",
"added_title": "Ajouté aux favoris",
"added_msg": "ajouté aux favoris",
"removed_title": "Retiré des favoris",
"removed_msg": "retiré des favoris"
},
"recent": {
"title": "Récents",
"clear": "Effacer les récents",
"accessed": "Consulté",
"empty_state": "Aucun fichier récent",
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici",
"loadMore": "Charger plus"
},
"notifications": {
"file_renamed": "Fichier renommé",
"file_renamed_to": "Fichier renommé en « {{name}} »",
"folder_renamed": "Dossier renommé",
"folder_renamed_to": "Dossier renommé en « {{name}} »",
"file_uploaded": "Fichier téléversé",
"file_deleted": "Fichier déplacé vers la corbeille",
"folder_deleted": "Dossier déplacé vers la corbeille",
"item_deleted_permanently": "Élément supprimé définitivement",
"trash_emptied": "Corbeille vidée avec succès",
"empty": "No notifications",
"title": "Notifications",
"link_created": "Lien créé",
"share_success": "Lien de partage créé avec succès",
"upload_files_section_title": "Dépôt non disponible ici",
"upload_files_section_body": "Accédez à la section Fichiers pour déposer des fichiers"
},
"batch": {
"one_selected": "1 élément sélectionné",
"n_selected": "{{count}} éléments sélectionnés",
"confirm_delete": "Voulez-vous vraiment déplacer {{count}} éléments vers la corbeille ?",
"move_title": "Déplacer {{count}} élément(s)",
"add_favorites": "Ajouter aux favoris",
"move_copy": "Déplacer ou copier"
},
"admin": {
"page_title": "Panneau d'administration",
"back_to_app": "Retour à OxiCloud",
"loading": "Chargement…",
"access_denied": "Accès refusé",
"access_denied_desc": "Privilèges d'administrateur requis.",
"sign_in": "Se connecter",
"tab_dashboard": "Tableau de bord",
"tab_users": "Utilisateurs",
"tab_oidc": "SSO / OIDC",
"total_users": "Utilisateurs totaux",
"active_users": "Utilisateurs actifs",
"admins": "Admins",
"version": "Version",
"storage_overview": "Aperçu du stockage",
"used": "Utilisé",
"total_quota": "Quota total",
"usage_pct": "Utilisation %",
"users_over_80": "Utilisateurs >80% quota",
"users_over_quota": "Utilisateurs dépassant le quota",
"system": "Système",
"auth_label": "Auth",
"oidc_label": "OIDC",
"quotas_label": "Quotas",
"enabled": "Activé",
"disabled": "Désactivé",
"active": "Actif",
"off": "Inactif",
"allow_registration": "Autoriser l'inscription publique",
"registration_warning": "L'inscription publique est désactivée. Seuls les admins peuvent créer des utilisateurs.",
"user_management": "Gestion des utilisateurs",
"create_user": "Créer un utilisateur",
"col_user": "Utilisateur",
"col_role": "Rôle",
"col_auth": "Auth",
"col_status": "Statut",
"col_storage": "Stockage",
"col_last_login": "Dernière connexion",
"col_actions": "Actions",
"loading_users": "Chargement des utilisateurs…",
"failed_load_users": "Échec du chargement",
"no_users_found": "Aucun utilisateur trouvé",
"showing_users": "Affichage {{from}}-{{to}} sur {{total}}",
"prev": "Précédent",
"next": "Suivant",
"inactive": "Inactif",
"you_badge": "(vous)",
"local": "Local",
"never": "Jamais",
"just_now": "À l'instant",
"minutes_ago": "il y a {{n}}min",
"hours_ago": "il y a {{n}}h",
"days_ago": "il y a {{n}}j",
"edit_quota_title": "Modifier le quota",
"reset_password_title": "Réinitialiser le mot de passe",
"toggle_role_title": "Changer de rôle",
"deactivate_title": "Désactiver",
"activate_title": "Activer",
"delete_title": "Supprimer",
"sso_title": "Authentification unique (OIDC / SSO)",
"enable_sso": "Activer l'authentification SSO",
"provider_name": "Nom du fournisseur",
"issuer_url": "URL de l'émetteur",
"issuer_url_hint": "URL de l'émetteur OpenID Connect",
"auto_discover": "Auto-découverte",
"discovering": "Découverte…",
"client_id": "Client ID",
"client_secret": "Client Secret",
"client_secret_placeholder": "Laisser vide pour conserver la valeur",
"secret_configured": "Un client secret est déjà configuré",
"callback_url": "URL de rappel",
"callback_url_hint": "(enregistrer dans votre IdP)",
"advanced_settings": "Paramètres avancés",
"scopes": "Scopes",
"auto_provision": "Provisionner automatiquement les utilisateurs",
"admin_groups": "Groupes admin",
"admin_groups_hint": "Noms de groupes OIDC séparés par des virgules",
"disable_password": "Désactiver la connexion par mot de passe (OIDC uniquement)",
"password_warning": "Cela empêchera TOUTES les connexions par mot de passe !",
"test_btn": "Tester",
"save_btn": "Enregistrer",
"saving": "Enregistrement…",
"settings_saved": "Paramètres enregistrés — OIDC est maintenant {{status}}",
"quota_modal_title": "Mettre à jour le quota",
"quota_user_label": "Utilisateur :",
"new_quota": "Nouveau quota",
"quota_unlimited_hint": "0 pour illimité",
"cancel": "Annuler",
"create_user_title": "Créer un nouvel utilisateur",
"username_label": "Nom d'utilisateur",
"username_placeholder": "jeandupont",
"username_hint": "3–32 caractères",
"password_label": "Mot de passe",
"password_placeholder": "Min 8 caractères",
"email_label": "E-mail",
"email_optional": "(facultatif)",
"email_placeholder": "utilisateur@exemple.com (auto-généré si vide)",
"role_label": "Rôle",
"role_user": "Utilisateur",
"role_admin": "Admin",
"quota_label": "Quota",
"creating": "Création…",
"reset_pw_title": "Réinitialiser le mot de passe",
"new_password_label": "Nouveau mot de passe",
"resetting": "Réinitialisation…",
"reset_btn": "Réinitialiser",
"confirm_role_change": "Changer le rôle en {{role}} ?",
"confirm_deactivate": "Voulez-vous vraiment désactiver cet utilisateur ?",
"confirm_activate": "Voulez-vous vraiment activer cet utilisateur ?",
"confirm_delete_user": "SUPPRIMER l'utilisateur \"{{name}}\" ? Irréversible !",
"confirm_action": "Confirmer l'action",
"confirm_yes": "Confirmer",
"confirm_no": "Annuler",
"error_username_short": "Le nom d'utilisateur doit contenir au moins 3 caractères",
"error_password_short": "Le mot de passe doit contenir au moins 8 caractères",
"error_generic": "Échec",
"error_network": "Erreur réseau : {{message}}",
"error_create_user": "Impossible de créer l'utilisateur",
"tab_storage": "Stockage",
"storage_title": "Configuration du stockage",
"storage_current_backend": "Backend actuel",
"storage_total_blobs": "Total des blobs",
"storage_total_size": "Taille totale",
"storage_dedup_ratio": "Taux de déduplication",
"storage_backend": "Backend",
"storage_local": "Local",
"storage_s3": "Compatible S3",
"storage_provider_preset": "Préréglage du fournisseur",
"storage_preset_custom": "Personnalisé",
"storage_endpoint_url": "URL du point de terminaison",
"storage_endpoint_hint": "Laisser vide pour AWS S3",
"storage_bucket": "Bucket",
"storage_region": "Région",
"storage_access_key": "Clé d'accès",
"storage_secret_key": "Clé secrète",
"storage_secret_configured": "Clé configurée",
"storage_key_placeholder": "Saisir une nouvelle clé",
"storage_path_style": "Forcer le style de chemin",
"storage_path_style_hint": "Requis pour MinIO et certains services compatibles S3",
"storage_test_connection": "Tester la connexion",
"storage_test_success": "Connexion réussie",
"storage_test_failure": "Échec de la connexion",
"storage_save": "Enregistrer la configuration",
"storage_saved": "Configuration enregistrée",
"storage_migration": "Migration des données",
"storage_migration_coming_soon": "Outils de migration bientôt disponibles",
"migration_status_label": "État de la migration",
"migration_start": "Démarrer la migration",
"migration_pause": "Pause",
"migration_resume": "Reprendre",
"migration_verify": "Vérifier",
"migration_complete": "Terminer",
"migration_started": "Migration démarrée",
"migration_paused_msg": "Migration en pause",
"migration_resumed_msg": "Migration reprise",
"migration_completed_msg": "Migration terminée avec succès",
"migration_verifying": "Vérification en cours...",
"migration_verify_passed": "Vérification réussie",
"migration_verify_failed": "Échec de la vérification",
"migration_failed_blobs": "Blobs échoués",
"testing": "Test en cours...",
"tab_smtp": "SMTP",
"smtp_title": "E-mail sortant (SMTP)",
"smtp_intro": "Le SMTP est configuré exclusivement via les variables d'environnement (OXICLOUD_SMTP_*). Les valeurs ci-dessous proviennent du serveur en cours d'exécution — pour les modifier, éditez l'environnement et redémarrez OxiCloud.",
"smtp_enabled_label": "État",
"smtp_enabled": "Activé",
"smtp_disabled": "Désactivé (hôte non défini)",
"smtp_test_title": "Envoyer un e-mail de test",
"smtp_test_intro": "Envoie un message de diagnostic au destinataire ci-dessous et affiche la réponse du serveur SMTP afin que vous puissiez la corréler avec les journaux de votre relais.",
"smtp_test_to": "Adresse du destinataire",
"smtp_send_test": "Envoyer l'e-mail de test",
"smtp_sending": "Envoi…",
"smtp_sent": "E-mail de test envoyé.",
"smtp_send_failed": "Échec de l'envoi.",
"smtp_server_code": "Le serveur a répondu",
"smtp_test_missing_to": "Veuillez saisir une adresse de destinataire.",
"smtp_not_configured": "Le SMTP n'est pas configuré sur ce serveur."
},
"profile": {
"page_title": "Profil",
"back_to_app": "Retour à OxiCloud",
"loading": "Chargement…",
"not_authenticated": "Non authentifié",
"not_authenticated_desc": "Connectez-vous pour voir votre profil.",
"sign_in": "Se connecter",
"role_admin": "Administrateur",
"role_user": "Utilisateur",
"account_details": "Détails du compte",
"username": "Nom d'utilisateur",
"email": "E-mail",
"role": "Rôle",
"last_login": "Dernière connexion",
"storage": "Stockage",
"used": "Utilisé",
"quota": "Quota",
"usage": "Utilisation",
"unlimited": "Illimité",
"app_passwords": "Mots de passe d'application",
"app_pw_desc": "Générez des mots de passe pour les clients WebDAV, CalDAV et CardDAV. Chaque mot de passe n'est affiché qu'une seule fois.",
"app_pw_label_placeholder": "Libellé (ex. Thunderbird, macOS)",
"generate": "Générer",
"generating": "Génération…",
"new_password_for": "Nouveau mot de passe pour",
"copy_warning": "Copiez ce mot de passe maintenant. Vous ne pourrez plus le revoir.",
"copy_to_clipboard": "Copier dans le presse-papiers",
"col_label": "Libellé",
"col_created": "Créé",
"col_last_used": "Dernière utilisation",
"col_status": "Statut",
"active": "Actif",
"revoked": "Révoqué",
"revoke_title": "Révoquer",
"no_app_passwords": "Aucun mot de passe d'application.",
"client_sessions": "Sessions client",
"client_sessions_desc": "Générées automatiquement lors de la connexion d'un client compatible Nextcloud.",
"col_client": "Client",
"never": "Jamais",
"just_now": "À l'instant",
"minutes_ago": "il y a {{n}} min",
"hours_ago": "il y a {{n}}h",
"days_ago": "il y a {{n}} jours",
"edit_profile": "Modifier le profil",
"edit_oidc_managed": "Pour modifier vos informations (nom, prénom, photo de profil, …), veuillez les mettre à jour chez votre fournisseur d'identité. Vos changements apparaîtront à votre prochaine connexion.",
"username_claim_hint": "2 à 64 caractères, lettres / chiffres / point / tiret / souligné. Une fois choisi, le nom d'utilisateur ne peut plus être modifié (les clients DAV/NextCloud en dépendent).",
"username_already_claimed": "Nom d'utilisateur fixé et non modifiable (les clients DAV/NextCloud en dépendent).",
"given_name": "Prénom",
"family_name": "Nom",
"notify_on_share": "M'avertir par e-mail quand quelqu'un partage avec moi",
"notify_on_share_hint": "Lorsque décoché, les partages apparaissent toujours dans votre compte — vous ne recevrez simplement pas d'e-mail à leur sujet.",
"save_profile": "Enregistrer",
"profile_saved": "Profil mis à jour",
"profile_no_changes": "Aucun changement à enregistrer.",
"profile_save_failed": "Échec de l'enregistrement",
"username_taken_error": "Ce nom d'utilisateur est déjà pris.",
"username_immutable_error": "Votre nom d'utilisateur est déjà défini et ne peut plus être modifié ici. Contactez un administrateur si vous souhaitez le renommer.",
"change_password": "Changer le mot de passe",
"current_password": "Mot de passe actuel",
"new_password": "Nouveau mot de passe",
"min_8_chars": "Au moins 8 caractères",
"confirm_password": "Confirmer le nouveau mot de passe",
"update_password": "Mettre à jour le mot de passe",
"updating": "Mise à jour…",
"password_updated": "Mot de passe mis à jour avec succès",
"passwords_no_match": "Les mots de passe ne correspondent pas",
"password_too_short": "Le mot de passe doit contenir au moins 8 caractères",
"password_change_failed": "Échec du changement de mot de passe",
"error_network": "Erreur réseau : {{message}}",
"error_label_required": "Veuillez entrer un libellé",
"error_create_pw": "Impossible de créer le mot de passe",
"confirm_revoke": "Révoquer le mot de passe \"{{label}}\" ? Les clients l'utilisant ne fonctionneront plus.",
"error_revoke": "Échec de la révocation",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Téléchargement en cours...",
"files": "fichiers",
"complete": "{{count}} / {{total}} téléchargés"
},
"storage_quota_exceeded": "Quota de stockage dépassé",
"sharedwithme": {
"pageTitle": "Partagé avec moi",
"pageDescription": "Fichiers et dossiers que d'autres utilisateurs ont partagés avec vous",
"emptyStateTitle": "Rien n'a encore été partagé avec vous",
"emptyStateDesc": "Les éléments partagés avec vous par d'autres utilisateurs apparaîtront ici",
"loadMore": "Charger plus",
"sharedBy": "Partagé par",
"colName": "Nom",
"colType": "Type",
"colSharedBy": "Partagé par",
"colDate": "Date de partage",
"colPermissions": "Permissions"
},
"groupby": {
"none": "Aucun",
"title": "Grouper par",
"type": "Type",
"type.folders": "Dossiers",
"owner": "Propriétaire",
"shareDate": "Date de partage",
"favoriteDate": "Date d'ajout aux favoris",
"accessedAt": "Date d'accès",
"modifiedAt": "Date de modification",
"createdAt": "Date de création",
"size": "Taille",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "Nouveau"
},
"dateBucket": {
"today": "Aujourd'hui",
"last7days": "7 derniers jours",
"last30days": "30 derniers jours"
},
"groups": {
"title": "Gérer les groupes",
"create_button": "Créer un groupe",
"create_dialog_title": "Nouveau groupe",
"edit_dialog_title": "Renommer le groupe",
"name_label": "Nom",
"name_placeholder": "ingenierie",
"description_label": "Description (facultatif)",
"members_section": "Membres",
"add_member_placeholder": "Ajouter un utilisateur ou un groupe…",
"no_members": "Aucun membre pour le moment.",
"remove_member": "Retirer",
"delete_group": "Supprimer le groupe",
"delete_confirm": "Supprimer le groupe « {name} » ? Les autorisations associées à ce groupe seront révoquées.",
"empty_state": "Aucun groupe pour le moment.",
"load_more": "Charger plus",
"back_to_list": "Retour",
"loading": "Chargement…",
"virtual_badge": "Système",
"member_count_zero": "Aucun membre",
"member_count_one": "1 membre",
"member_count_other": "{count} membres",
"delete_confirm_label": "Tapez le nom du groupe pour confirmer :",
"delete_confirm_mismatch": "Tapez le nom du groupe exactement pour confirmer.",
"virtual_internal_name": "Interne",
"members_loading": "Chargement des membres…",
"members_empty": "Aucun membre",
"virtual_internal_explanation": "Tous les utilisateurs internes de ce serveur"
},
"myshares": {
"copyLink": "Copier le lien",
"deleteLink": "Supprimer le lien",
"notifyByEmail": "Notifier par e-mail",
"notifyFailed": "Impossible d'envoyer la notification.",
"notifyGroupMembers": "Notifier les membres du groupe",
"notifyRateLimited": "Trop de notifications pour ce destinataire — réessayez plus tard.",
"removeAccess": "Retirer l'accès",
"resendInvitation": "Renvoyer l'e-mail d'invitation"
},
"sort": {
"asc": "croissant",
"desc": "décroissant"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "यह साइन-इन लिंक अब वैध नहीं है",
"expired_body": "लिंक समाप्त हो गया हो सकता है या पहले से उपयोग किया जा चुका हो सकता है। हम आपको एक नया भेज सकते हैं — यह कुछ ही सेकंड में आपके इनबॉक्स में पहुँच जाएगा।",
"resend_to": "{{email}} को नया लिंक भेजें",
"generic_unavailable": "यह साइन-इन लिंक अब वैध नहीं है। यह पहले से उपयोग किया जा चुका हो सकता है या समाप्त हो गया हो सकता है। लॉगिन पृष्ठ से नया लिंक माँगें।",
"service_unavailable": "इस सर्वर पर मैजिक-लिंक साइन-इन सक्षम नहीं है।",
"internal_error": "साइन इन करते समय कुछ गलत हो गया। कृपया फिर से प्रयास करें।",
"resend_failure": "लिंक भेजते समय कुछ गलत हो गया। कृपया फिर से प्रयास करें।",
"cross_browser_title": "इस डिवाइस पर साइन-इन जारी रखें?",
"cross_browser_body": "आपने यह साइन-इन लिंक उससे भिन्न ब्राउज़र या डिवाइस में खोला है जहाँ से आपने इसका अनुरोध किया था।",
"cross_browser_warning": "यदि आपने यह लिंक माँगा है, तो आगे बढ़ना सुरक्षित है। यदि नहीं, तो इस पृष्ठ को बंद कर दें — जारी रखें पर क्लिक करने से कोई और आपके खाते में साइन-इन हो जाएगा।",
"cross_browser_continue": "जारी रखें और साइन इन करें",
"resend_confirmation_title": "अपना इनबॉक्स देखें",
"resend_confirmation_body": "यदि साइन-इन लिंक किसी सक्रिय खाते का था, तो अभी एक नया लिंक भेजा गया है। कृपया अपना इनबॉक्स देखें।",
"return_link": "OxiCloud पर वापस जाएँ"
},
"email": {
"invitation": {
"subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया",
"body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nइसे नीचे दिए गए लिंक पर क्लिक करके खोलें:\n{{link}}\n\nलिंक केवल एक बार काम करता है और {{ttl_hours}} घंटों में समाप्त हो जाता है।\nयदि आप इस आमंत्रण की अपेक्षा नहीं कर रहे थे, तो आप इस संदेश को अनदेखा कर सकते हैं।\n\n— OxiCloud"
},
"login": {
"subject": "OxiCloud में साइन इन करें",
"body": "नमस्ते,\n\nOxiCloud में साइन इन करने के लिए नीचे दिए गए लिंक का उपयोग करें। लिंक केवल एक बार काम करता है और {{ttl_minutes}} मिनट में समाप्त हो जाता है। इसे उसी डिवाइस पर खोलें जहाँ से आपने अनुरोध किया था।\n\n{{link}}\n\nयदि आपने यह साइन-इन लिंक नहीं माँगा था, तो आप इस संदेश को अनदेखा कर सकते हैं — किसी और कार्रवाई की आवश्यकता नहीं है।\n\n— OxiCloud"
},
"kind_file": "फ़ाइल",
"kind_folder": "फ़ोल्डर",
"english_fallback_divider": "--- अंग्रेज़ी संस्करण नीचे ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया",
"body": "{{inviter_full}} ने OxiCloud पर आपके साथ एक {{kind}} साझा किया है।\n\nअपना नया साझाकरण देखने के लिए OxiCloud खोलें:\n{{login_link}}\n\nहो सकता है आपके पास {{inviter}} से और भी नए साझाकरण हों — साइन इन करें और अपने सभी साझा किए गए आइटम देखें।\n\n— OxiCloud\n\nआपको यह संदेश इसलिए मिल रहा है क्योंकि आपका OxiCloud खाता है और साझाकरण-सूचना प्राथमिकता चालू है। आप इसे अपनी प्रोफ़ाइल में बंद कर सकते हैं (जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें)।"
}
}
},
"app": {
"title": "OxiCloud",
"description": "न्यूनतम क्लाउड स्टोरेज सिस्टम"
},
"nav": {
"files": "फ़ाइलें",
"shared": "साझा",
"recent": "हाल ही में",
"favorites": "पसंदीदा",
"photos": "फ़ोटो",
"music": "संगीत",
"trash": "रद्दी",
"sharedwithme": "मेरे साथ साझा किए गए"
},
"photos": {
"empty_state": "अभी कोई फ़ोटो नहीं",
"empty_hint": "यहाँ देखने के लिए चित्र या वीडियो अपलोड करें",
"items_selected": "चयनित",
"view_daily": "दिन",
"view_monthly": "महीना",
"view_yearly": "वर्ष"
},
"music": {
"create_playlist": "प्लेलिस्ट बनाएँ",
"playlists": "प्लेलिस्ट",
"no_playlists": "अभी कोई प्लेलिस्ट नहीं",
"select_playlist": "प्लेलिस्ट चुनें",
"select_hint": "साइडबार से प्लेलिस्ट चुनें या नई बनाएँ",
"add_tracks": "ट्रैक जोड़ें",
"no_tracks": "इस प्लेलिस्ट में कोई ट्रैक नहीं",
"unknown_artist": "अज्ञात कलाकार",
"unknown_title": "अज्ञात",
"confirm_delete": "इस प्लेलिस्ट को हटाएँ?",
"playlist_name": "प्लेलिस्ट का नाम",
"create": "बनाएँ",
"delete": "हटाएँ",
"share": "साझा करें",
"edit": "संपादित करें",
"play_all": "सभी चलाएँ",
"shuffle": "शफल",
"repeat": "दोहराएँ",
"repeat_one": "एक दोहराएँ",
"queue": "कतार",
"queue_empty": "कतार खाली है",
"not_playing": "नहीं चल रहा",
"play": "चलाएँ",
"pause": "रोकें",
"previous": "पिछला",
"next": "अगला",
"volume": "आवाज़",
"mute": "म्यूट",
"unmute": "अनम्यूट",
"title": "शीर्षक",
"artist": "कलाकार",
"album": "एल्बम",
"tracks": "ट्रैक",
"add": "जोड़ें",
"added": "जोड़ा गया!",
"added_to_playlist": "प्लेलिस्ट में जोड़ा गया",
"add_to_playlist": "प्लेलिस्ट में जोड़ें",
"load_error": "प्लेलिस्ट लोड करने में त्रुटि",
"add_error": "प्लेलिस्ट में ट्रैक नहीं जोड़े जा सके",
"no_playlists_yet": "अभी तक कोई प्लेलिस्ट नहीं। पहले एक बनाएं!",
"selected_files": "चयनित:",
"error": "त्रुटि",
"search_audio": "ऑडियो फ़ाइलें खोजें…",
"no_audio_files": "कोई ऑडियो फ़ाइल नहीं मिली",
"selected": "चयनित",
"loading": "लोड हो रहा है…",
"search_error": "ऑडियो फ़ाइलें लोड नहीं हो सकीं",
"adding": "जोड़ा जा रहा है…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "फ़ाइलें खोजें...",
"new_folder": "नया फ़ोल्डर",
"upload": "अपलोड",
"upload_files": "फ़ाइलें अपलोड करें",
"upload_folder": "फ़ोल्डर अपलोड करें",
"upload.uploading": "अपलोड हो रहा है...",
"upload.complete": "{count} / {total} अपलोड हुईं",
"upload.files": "फ़ाइलें",
"rename": "नाम बदलें",
"move": "यहाँ ले जाएँ...",
"move_to": "यहाँ ले जाएँ",
"delete": "हटाएँ",
"download": "डाउनलोड",
"view": "देखें",
"cancel": "रद्द करें",
"confirm": "पुष्टि करें",
"share": "साझा करें",
"favorite": "पसंदीदा में जोड़ें",
"unfavorite": "पसंदीदा से हटाएँ",
"copy": "कॉपी करें",
"notify": "सूचित करें",
"send": "भेजें",
"clear_recent": "हाल ही का साफ़ करें",
"logout": "लॉग आउट",
"create": "बनाएँ",
"search_btn": "खोजें",
"close": "बंद करें",
"delete_permanently": "स्थायी रूप से हटाएँ",
"empty_trash": "रद्दी खाली करें",
"open_parent_folder": "मूल फ़ोल्डर पर जाएं",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "दिखावट",
"about": "OxiCloud के बारे में",
"about_description": "Rust और Clean Architecture से बना क्लाउड स्टोरेज प्लेटफ़ॉर्म। तेज़, सुरक्षित और निजी।",
"admin_panel": "एडमिन पैनल",
"profile": "मेरी प्रोफ़ाइल",
"role_user": "उपयोगकर्ता",
"theme": {
"light": "हल्का",
"dark": "गहरा",
"auto": "सिस्टम जैसा"
},
"manage_groups": "समूह प्रबंधित करें"
},
"share": {
"dialogTitle": "शेयर लिंक",
"linkLabel": "शेयर लिंक:",
"copyLink": "कॉपी",
"permissions": "अनुमतियाँ:",
"permissionRead": "पढ़ें",
"permissionWrite": "लिखें",
"permissionReshare": "पुनः साझा करें",
"password": "पासवर्ड सुरक्षा:",
"generatePassword": "जनरेट करें",
"expiration": "समाप्ति तिथि:",
"update": "शेयर अपडेट करें",
"remove": "शेयर हटाएँ",
"notifyTitle": "सूचना भेजें",
"notifyEmailLabel": "ईमेल पता:",
"notifyMessageLabel": "संदेश (वैकल्पिक):",
"notifySend": "सूचना भेजें",
"shareWithOthers": "दूसरों के साथ साझा करें",
"sharePublicly": "सार्वजनिक रूप से साझा करें",
"shareSettings": "साझा सेटिंग्स",
"shareCopied": "लिंक क्लिपबोर्ड पर कॉपी हुआ",
"shareCreated": "शेयर लिंक सफलतापूर्वक बनाया गया",
"shareUpdated": "शेयर सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"shareRemoved": "शेयर सफलतापूर्वक हटाया गया",
"inviteByEmail": "ईमेल द्वारा आमंत्रित करें — आमंत्रण भेजा जाएगा",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "शेयर लिंक",
"share_linkLabel": "शेयर लिंक:",
"share_copyLink": "कॉपी",
"share_permissions": "अनुमतियाँ:",
"share_permissionRead": "पढ़ें",
"share_permissionWrite": "लिखें",
"share_permissionReshare": "पुनः साझा करें",
"share_password": "पासवर्ड सुरक्षा:",
"share_generatePassword": "जनरेट करें",
"share_expiration": "समाप्ति तिथि:",
"share_update": "शेयर अपडेट करें",
"share_remove": "शेयर हटाएँ",
"share_notifyTitle": "सूचना भेजें",
"share_notifyEmailLabel": "ईमेल पता:",
"share_notifyMessageLabel": "संदेश (वैकल्पिक):",
"share_notifySend": "सूचना भेजें",
"shared": {
"backToFiles": "फ़ाइलों पर वापस",
"pageTitle": "साझा संसाधन",
"pageDescription": "अपनी साझा फ़ाइलें और फ़ोल्डर प्रबंधित करें",
"filterType": "प्रकार:",
"filterAll": "सभी",
"filterFiles": "फ़ाइलें",
"filterFolders": "फ़ोल्डर",
"sortBy": "क्रमबद्ध:",
"sortByName": "नाम",
"sortByDate": "साझा तिथि",
"sortByExpiration": "समाप्ति",
"search": "खोजें",
"colName": "नाम",
"colType": "प्रकार",
"colDateShared": "साझा तिथि",
"colExpiration": "समाप्ति",
"colPermissions": "अनुमतियाँ",
"colPassword": "पासवर्ड",
"colActions": "कार्य",
"emptyStateTitle": "अभी कोई साझा संसाधन नहीं",
"emptyStateDesc": "जब आप फ़ाइलें या फ़ोल्डर साझा करेंगे, वे यहाँ दिखेंगे",
"goToFiles": "फ़ाइलों पर जाएँ",
"typeFile": "फ़ाइल",
"typeFolder": "फ़ोल्डर",
"noExpiration": "कोई समाप्ति नहीं",
"hasPassword": "हाँ",
"noPassword": "नहीं",
"editShare": "शेयर संपादित करें",
"notifyShare": "किसी को सूचित करें",
"copyLink": "लिंक कॉपी करें",
"removeShare": "शेयर हटाएँ",
"linkCopied": "लिंक क्लिपबोर्ड पर कॉपी हुआ!",
"linkCopyFailed": "लिंक कॉपी करने में विफल",
"itemUpdated": "शेयर सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"itemRemoved": "शेयर सफलतापूर्वक हटाया गया",
"invalidEmail": "कृपया एक वैध ईमेल पता दर्ज करें",
"notificationSent": "सूचना सफलतापूर्वक भेजी गई",
"notificationFailed": "सूचना भेजने में विफल",
"shared_backToFiles": "फ़ाइलों पर वापस",
"shared_pageTitle": "साझा संसाधन",
"shared_pageDescription": "अपनी साझा फ़ाइलें और फ़ोल्डर प्रबंधित करें",
"shared_filterType": "प्रकार:",
"shared_filterAll": "सभी",
"shared_filterFiles": "फ़ाइलें",
"shared_filterFolders": "फ़ोल्डर",
"shared_sortBy": "क्रमबद्ध:",
"shared_sortByName": "नाम",
"shared_sortByDate": "साझा तिथि",
"shared_sortByExpiration": "समाप्ति",
"shared_search": "खोजें",
"shared_colName": "नाम",
"shared_colType": "प्रकार",
"shared_colDateShared": "साझा तिथि",
"shared_colExpiration": "समाप्ति",
"shared_colPermissions": "अनुमतियाँ",
"shared_colPassword": "पासवर्ड",
"shared_colActions": "कार्य",
"shared_emptyStateTitle": "अभी कोई साझा संसाधन नहीं",
"shared_emptyStateDesc": "जब आप फ़ाइलें या फ़ोल्डर साझा करेंगे, वे यहाँ दिखेंगे",
"shared_goToFiles": "फ़ाइलों पर जाएँ",
"shared_typeFile": "फ़ाइल",
"shared_typeFolder": "फ़ोल्डर",
"shared_noExpiration": "कोई समाप्ति नहीं",
"shared_hasPassword": "हाँ",
"shared_noPassword": "नहीं",
"shared_editShare": "शेयर संपादित करें",
"shared_notifyShare": "किसी को सूचित करें",
"shared_copyLink": "लिंक कॉपी करें",
"shared_removeShare": "शेयर हटाएँ",
"shared_linkCopied": "लिंक क्लिपबोर्ड पर कॉपी हुआ!",
"shared_linkCopyFailed": "लिंक कॉपी करने में विफल",
"shared_itemUpdated": "शेयर सेटिंग्स सफलतापूर्वक अपडेट हुईं",
"shared_itemRemoved": "शेयर सफलतापूर्वक हटाया गया",
"shared_invalidEmail": "कृपया एक वैध ईमेल पता दर्ज करें",
"shared_notificationSent": "सूचना सफलतापूर्वक भेजी गई",
"shared_notificationFailed": "सूचना भेजने में विफल"
},
"files": {
"name": "नाम",
"type": "प्रकार",
"size": "आकार",
"modified": "संशोधित",
"no_files": "इस फ़ोल्डर में कोई फ़ाइल नहीं",
"empty_hint": "शुरू करने के लिए ह़ैलें अपलोड करें या होल्डर बनाएँ",
"loading": "फ़ाइलें लोड हो रही हैं…",
"view_grid": "ग्रिड दृश्य",
"view_list": "सूची दृश्य",
"file_types": {
"document": "दस्तावेज़",
"image": "चित्र",
"video": "वीडियो",
"audio": "ऑडियो",
"pdf": "PDF",
"text": "टेक्स्ट",
"folder": "फ़ोल्डर",
"spreadsheet": "स्प्रेडशीट",
"presentation": "प्रेज़ेंटेशन",
"archive": "संग्रह",
"installer": "इंस्टॉलर",
"code": "कोड"
},
"owner": "स्वामी"
},
"dialogs": {
"rename_folder": "फ़ोल्डर का नाम बदलें",
"rename_file": "फ़ाइल का नाम बदलें",
"new_name": "नया नाम",
"new_folder_title": "नया फ़ोल्डर",
"folder_name": "फ़ोल्डर का नाम",
"folder_placeholder": "मेरा फ़ोल्डर",
"rename_title": "नाम बदलें",
"move_file": "फ़ाइल ले जाएँ",
"move_folder": "फ़ोल्डर ले जाएँ",
"select_destination": "गंतव्य फ़ोल्डर चुनें:",
"select_this_folder": "यह फ़ोल्डर चुनें",
"go_to_parent": ".. (पैरेंट फ़ोल्डर)",
"no_subfolders": "कोई सब-फ़ोल्डर नहीं",
"root": "रूट",
"delete_confirmation": "क्या आप वाकई हटाना चाहते हैं",
"and_contents": "और इसकी सभी सामग्री",
"no_undo": "यह कार्य पूर्ववत नहीं किया जा सकता",
"confirm_title": "कार्य की पुष्टि करें",
"confirm_delete": "रद्दी में भेजें",
"confirm_delete_file": "क्या आप वाकई फ़ाइल \"{{name}}\" को रद्दी में भेजना चाहते हैं?",
"confirm_delete_folder": "क्या आप वाकई फ़ोल्डर \"{{name}}\" और उसकी सभी सामग्री को रद्दी में भेजना चाहते हैं?",
"confirm_permanent_delete": "स्थायी रूप से हटाएँ",
"confirm_permanent_delete_msg": "क्या आप वाकई इस आइटम को स्थायी रूप से हटाना चाहते हैं? यह कार्य पूर्ववत नहीं किया जा सकता।",
"confirm_empty_trash": "रद्दी खाली करें",
"confirm_delete_share": "शेयर लिंक हटाएँ",
"confirm_delete_share_msg": "क्या आप वाकई इस शेयर लिंक को हटाना चाहते हैं?",
"share_file": "फ़ाइल साझा करें",
"share_folder": "फ़ोल्डर साझा करें",
"existing_shares": "मौजूदा शेयर",
"share_options": "शेयर विकल्प",
"password": "पासवर्ड",
"expiration": "समाप्ति",
"permissions": "अनुमतियाँ",
"generated_link": "जनरेट किया गया लिंक",
"notify": "सूचना भेजें",
"recipient": "प्राप्तकर्ता",
"message": "संदेश",
"move_to_home": "होम फ़ोल्डर में ले जाएं"
},
"dropzone": {
"drag_files": "फ़ाइलें यहाँ खींचें या चुनने के लिए क्लिक करें",
"drop_files": "अपलोड करने के लिए फ़ाइलें छोड़ें"
},
"permissions": {
"read": "पढ़ें",
"write": "लिखें",
"reshare": "पुनः साझा करें"
},
"errors": {
"file_not_found": "फ़ाइल नहीं मिली",
"folder_not_found": "फ़ोल्डर नहीं मिला",
"delete_error": "हटाने में त्रुटि",
"upload_error": "फ़ाइल अपलोड करने में त्रुटि",
"rename_error": "नाम बदलने में त्रुटि",
"move_error": "ले जाने में त्रुटि",
"empty_name": "नाम खाली नहीं हो सकता",
"name_exists": "इस नाम की फ़ाइल या फ़ोल्डर पहले से मौजूद है",
"generic_error": "एक त्रुटि हुई है",
"group_name_invalid": "समूह का नाम ईमेल उपसर्ग प्रारूप के अनुरूप होना चाहिए (अक्षर, अंक, बिंदु, डैश, अंडरस्कोर; 1–64 वर्ण).",
"group_cycle": "यह सदस्य समूहों के बीच चक्रीय संदर्भ बनाएगा।",
"group_depth_exceeded": "यह नेस्टिंग गहराई अनुमत अधिकतम (8) से अधिक है।",
"group_virtual_immutable": "«Internal» समूह सिस्टम द्वारा प्रबंधित है और इसे संशोधित नहीं किया जा सकता।",
"group_not_found": "समूह नहीं मिला।",
"group_name_taken": "इस नाम का एक समूह पहले से मौजूद है।"
},
"breadcrumb": {
"home": "होम"
},
"trash": {
"empty_trash": "रद्दी खाली करें",
"empty_state": "रद्दी खाली है",
"original_location": "मूल स्थान",
"deleted_date": "हटाने की तिथि",
"remaining": "शेष",
"actions": "कार्य",
"restore": "पुनर्स्थापित करें",
"delete_permanently": "स्थायी रूप से हटाएँ",
"empty_confirm": "क्या आप वाकई रद्दी खाली करना चाहते हैं? यह सभी आइटम स्थायी रूप से हटा देगा।",
"groupby": {
"remaining_days": "शेष दिन",
"trashed_time": "हटाने का समय"
}
},
"daysRemaining": {
"expired": "समाप्त",
"today": "आज",
"tomorrow": "कल",
"inDays": "{{count}} दिन"
},
"expiryChip": {
"never": "कभी समाप्त नहीं होता",
"expired": "समाप्त",
"today": "आज समाप्त होता है",
"tomorrow": "कल समाप्त होता है",
"inDays": "{{count}} दिनों में समाप्त होता है",
"onDate": "{{date}} को समाप्त होता है"
},
"auth": {
"login_title": "साइन इन",
"username": "उपयोगकर्ता नाम",
"username_placeholder": "अपना उपयोगकर्ता नाम दर्ज करें",
"login_identifier": "उपयोगकर्ता नाम या ईमेल",
"login_identifier_placeholder": "अपना उपयोगकर्ता नाम या ईमेल दर्ज करें",
"password": "पासवर्ड",
"password_placeholder": "अपना पासवर्ड दर्ज करें",
"login_button": "साइन इन",
"no_account": "खाता नहीं है?",
"register": "साइन अप करें",
"admin_setup": "पहली बार?",
"setup": "एडमिन सेटअप करें",
"register_title": "खाता बनाएँ",
"email": "ईमेल",
"email_placeholder": "अपना ईमेल दर्ज करें",
"confirm_password": "पासवर्ड की पुष्टि करें",
"confirm_password_placeholder": "अपना पासवर्ड पुष्टि करें",
"register_button": "खाता बनाएँ",
"have_account": "पहले से खाता है?",
"login": "साइन इन",
"setup_title": "प्रारंभिक सेटअप",
"setup_step1": "एडमिन",
"setup_step2": "सिस्टम",
"setup_step3": "पूर्ण",
"admin_username": "एडमिन उपयोगकर्ता नाम",
"admin_email": "एडमिन ईमेल",
"admin_password": "एडमिन पासवर्ड",
"create_admin": "एडमिन बनाएँ",
"back_to_login": "पहले से सेटअप है?",
"admin_success": "एडमिन खाता सफलतापूर्वक बनाया गया! अब आप साइन इन कर सकते हैं।",
"account_success": "खाता सफलतापूर्वक बनाया गया! अब आप साइन इन कर सकते हैं।",
"passwords_mismatch": "पासवर्ड मेल नहीं खाते",
"admin_create_error": "एडमिन खाता बनाने में त्रुटि",
"or": "या",
"sso_login": "SSO से साइन इन करें",
"sso_login_provider": "{{provider}} से साइन इन करें",
"magicLinkHint": "पासवर्ड नहीं है? अपना ईमेल दर्ज करें और हम आपको एक बार उपयोग होने वाला साइन-इन लिंक भेज देंगे।",
"magicLinkEmailLabel": "ईमेल पता",
"magicLinkEmailPlaceholder": "you@example.com",
"magicLinkSubmit": "साइन-इन लिंक भेजें",
"magicLinkSent": "यदि उस ईमेल के लिए कोई खाता मौजूद है, तो एक साइन-इन लिंक भेज दिया गया है। अपना इनबॉक्स देखें।",
"magicLinkUnavailable": "इस सर्वर पर ईमेल द्वारा साइन-इन उपलब्ध नहीं है।",
"magicLinkNetworkError": "सर्वर से कनेक्ट नहीं हो सका: {{message}}",
"magicLinkToggle": "No password? Email me a sign-in link",
"passwordsMatch": "Passwords match",
"capsLock": "Caps Lock is on"
},
"storage": {
"title": "स्टोरेज",
"calculating": "गणना हो रही है...",
"used": "{{percentage}}% उपयोग ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "इस फ़ाइल प्रकार का पूर्वावलोकन नहीं किया जा सकता।",
"download_file": "फ़ाइल डाउनलोड करें",
"zoom_in": "ज़ूम इन",
"zoom_out": "ज़ूम आउट",
"zoom_reset": "ज़ूम रीसेट"
},
"language_selector": {
"title": "स्वागत है!",
"subtitle": "जारी रखने के लिए अपनी भाषा चुनें",
"continue": "आगे बढ़ें",
"languages": {
"en": "English",
"es": "Español",
"zh": "中文",
"fa": "فارسی",
"fr": "Français",
"de": "Deutsch",
"pt": "Português",
"hi": "हिन्दी",
"ar": "العربية",
"it": "Italiano",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "अभी कोई पसंदीदा नहीं",
"empty_hint": "पसंदीदा में जोड़ने के लिए फ़ाइलों या फ़ोल्डर को स्टार करें",
"add": "पसंदीदा में जोड़ें",
"remove": "पसंदीदा से हटाएँ",
"added_title": "पसंदीदा में जोड़ा गया",
"added_msg": "पसंदीदा में जोड़ा गया",
"removed_title": "पसंदीदा से हटाया गया",
"removed_msg": "पसंदीदा से हटाया गया"
},
"recent": {
"title": "हाल ही में",
"clear": "हाल ही का साफ़ करें",
"accessed": "एक्सेस किया",
"empty_state": "कोई हाल की फ़ाइलें नहीं",
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
"loadMore": "और लोड करें"
},
"notifications": {
"file_renamed": "फ़ाइल का नाम बदला गया",
"file_renamed_to": "फ़ाइल का नाम \"{{name}}\" रखा गया",
"folder_renamed": "फ़ोल्डर का नाम बदला गया",
"folder_renamed_to": "फ़ोल्डर का नाम \"{{name}}\" रखा गया",
"file_uploaded": "फ़ाइल अपलोड हुई",
"file_deleted": "फ़ाइल रद्दी में भेजी गई",
"folder_deleted": "फ़ोल्डर रद्दी में भेजा गया",
"item_deleted_permanently": "आइटम स्थायी रूप से हटाया गया",
"trash_emptied": "रद्दी सफलतापूर्वक खाली की गई",
"title": "सूचनाएँ",
"empty": "कोई सूचना नहीं",
"link_created": "लिंक बनाया गया",
"share_success": "शेयर लिंक सफलतापूर्वक बनाया गया",
"upload_files_section_title": "यहाँ अपलोड उपलब्ध नहीं है",
"upload_files_section_body": "फ़ाइलें अपलोड करने के लिए फ़ाइलें अनुभाग पर जाएँ"
},
"batch": {
"one_selected": "1 आइटम चयनित",
"n_selected": "{{count}} आइटम चयनित",
"confirm_delete": "क्या आप वाकई {{count}} आइटम रद्दी में भेजना चाहते हैं?",
"move_title": "{{count}} आइटम ले जाएँ",
"add_favorites": "पसंदीदा में जोड़ें",
"move_copy": "ले जाएँ या कॉपी करें"
},
"admin": {
"page_title": "एडमिन पैनल",
"back_to_app": "OxiCloud पर वापस",
"loading": "लोड हो रहा है…",
"access_denied": "पहुंच अस्वीकृत",
"access_denied_desc": "व्यवस्थापक विशेषाधिकार आवश्यक।",
"sign_in": "साइन इन",
"tab_dashboard": "डैशबोर्ड",
"tab_users": "उपयोगकर्ता",
"tab_oidc": "SSO / OIDC",
"total_users": "कुल उपयोगकर्ता",
"active_users": "सक्रिय उपयोगकर्ता",
"admins": "व्यवस्थापक",
"version": "संस्करण",
"storage_overview": "स्टोरेज अवलोकन",
"used": "उपयोग किया",
"total_quota": "कुल कोटा",
"usage_pct": "उपयोग %",
"users_over_80": ">80% कोटा वाले",
"users_over_quota": "कोटा से अधिक",
"system": "सिस्टम",
"auth_label": "प्रमाणीकरण",
"oidc_label": "OIDC",
"quotas_label": "कोटा",
"enabled": "सक्षम",
"disabled": "अक्षम",
"active": "सक्रिय",
"off": "बंद",
"allow_registration": "सार्वजनिक पंजीकरण की अनुमति",
"registration_warning": "सार्वजनिक पंजीकरण अक्षम है। केवल व्यवस्थापक उपयोगकर्ता बना सकते हैं।",
"user_management": "उपयोगकर्ता प्रबंधन",
"create_user": "उपयोगकर्ता बनाएं",
"col_user": "उपयोगकर्ता",
"col_role": "भूमिका",
"col_auth": "प्रमाणीकरण",
"col_status": "स्थिति",
"col_storage": "स्टोरेज",
"col_last_login": "अंतिम लॉगिन",
"col_actions": "कार्रवाई",
"loading_users": "उपयोगकर्ता लोड हो रहे हैं…",
"failed_load_users": "लोड करने में विफल",
"no_users_found": "कोई उपयोगकर्ता नहीं मिला",
"showing_users": "{{from}}-{{to}} / {{total}} दिखा रहे हैं",
"prev": "पिछला",
"next": "अगला",
"inactive": "निष्क्रिय",
"you_badge": "(आप)",
"local": "स्थानीय",
"never": "कभी नहीं",
"just_now": "अभी",
"minutes_ago": "{{n}} मिनट पहले",
"hours_ago": "{{n}} घंटे पहले",
"days_ago": "{{n}} दिन पहले",
"edit_quota_title": "कोटा संपादित करें",
"reset_password_title": "पासवर्ड रीसेट",
"toggle_role_title": "भूमिका बदलें",
"deactivate_title": "निष्क्रिय करें",
"activate_title": "सक्रिय करें",
"delete_title": "हटाएं",
"sso_title": "सिंगल साइन-ऑन (OIDC / SSO)",
"enable_sso": "SSO सक्षम करें",
"provider_name": "प्रदाता का नाम",
"issuer_url": "जारीकर्ता URL",
"issuer_url_hint": "OpenID Connect जारीकर्ता URL",
"auto_discover": "स्वतः खोज",
"discovering": "खोज रहे हैं…",
"client_id": "क्लाइंट ID",
"client_secret": "क्लाइंट सीक्रेट",
"client_secret_placeholder": "वर्तमान मान बनाए रखने के लिए खाली छोड़ें",
"secret_configured": "क्लाइंट सीक्रेट पहले से कॉन्फ़िगर है",
"callback_url": "कॉलबैक URL",
"callback_url_hint": "(अपने IdP में पंजीकृत करें)",
"advanced_settings": "उन्नत सेटिंग्स",
"scopes": "स्कोप",
"auto_provision": "पहले लॉगिन पर स्वतः प्रावधान",
"admin_groups": "व्यवस्थापक समूह",
"admin_groups_hint": "अल्पविराम-पृथक OIDC समूह नाम",
"disable_password": "पासवर्ड लॉगिन अक्षम (केवल OIDC)",
"password_warning": "सभी पासवर्ड लॉगिन रुक जाएंगे!",
"test_btn": "परीक्षण",
"save_btn": "सहेजें",
"saving": "सहेज रहे हैं…",
"settings_saved": "सेटिंग्स सहेजी गईं — OIDC अब {{status}}",
"quota_modal_title": "स्टोरेज कोटा अपडेट",
"quota_user_label": "उपयोगकर्ता:",
"new_quota": "नया कोटा",
"quota_unlimited_hint": "असीमित के लिए 0",
"cancel": "रद्द करें",
"create_user_title": "नया उपयोगकर्ता बनाएं",
"username_label": "उपयोगकर्ता नाम",
"username_placeholder": "username",
"username_hint": "3–32 अक्षर",
"password_label": "पासवर्ड",
"password_placeholder": "न्यूनतम 8 अक्षर",
"email_label": "ईमेल",
"email_optional": "(वैकल्पिक)",
"email_placeholder": "user@example.com (खाली होने पर स्वतः)",
"role_label": "भूमिका",
"role_user": "उपयोगकर्ता",
"role_admin": "व्यवस्थापक",
"quota_label": "कोटा",
"creating": "बना रहे हैं…",
"reset_pw_title": "पासवर्ड रीसेट",
"new_password_label": "नया पासवर्ड",
"resetting": "रीसेट हो रहा है…",
"reset_btn": "रीसेट",
"confirm_role_change": "भूमिका {{role}} में बदलें?",
"confirm_deactivate": "इस उपयोगकर्ता को निष्क्रिय करें?",
"confirm_activate": "इस उपयोगकर्ता को सक्रिय करें?",
"confirm_delete_user": "उपयोगकर्ता \"{{name}}\" हटाएं? पूर्ववत नहीं होगा!",
"confirm_action": "कार्रवाई की पुष्टि",
"confirm_yes": "पुष्टि",
"confirm_no": "रद्द",
"error_username_short": "नाम कम से कम 3 अक्षर",
"error_password_short": "पासवर्ड कम से कम 8 अक्षर",
"error_generic": "विफल",
"error_network": "नेटवर्क त्रुटि: {{message}}",
"error_create_user": "उपयोगकर्ता बनाने में विफल",
"tab_storage": "स्टोरेज",
"storage_title": "स्टोरेज कॉन्फ़िगरेशन",
"storage_current_backend": "वर्तमान बैकएंड",
"storage_total_blobs": "कुल ब्लॉब्स",
"storage_total_size": "कुल आकार",
"storage_dedup_ratio": "डीडुप्लिकेशन अनुपात",
"storage_backend": "बैकएंड",
"storage_local": "स्थानीय",
"storage_s3": "S3 संगत",
"storage_provider_preset": "प्रदाता प्रीसेट",
"storage_preset_custom": "कस्टम",
"storage_endpoint_url": "एंडपॉइंट URL",
"storage_endpoint_hint": "AWS S3 के लिए खाली छोड़ें",
"storage_bucket": "बकेट",
"storage_region": "क्षेत्र",
"storage_access_key": "एक्सेस की",
"storage_secret_key": "सीक्रेट की",
"storage_secret_configured": "की कॉन्फ़िगर की गई",
"storage_key_placeholder": "नई की दर्ज करें",
"storage_path_style": "पाथ स्टाइल फ़ोर्स करें",
"storage_path_style_hint": "MinIO और कुछ S3-संगत सेवाओं के लिए आवश्यक",
"storage_test_connection": "कनेक्शन परीक्षण",
"storage_test_success": "कनेक्शन सफल",
"storage_test_failure": "कनेक्शन विफल",
"storage_save": "कॉन्फ़िगरेशन सहेजें",
"storage_saved": "कॉन्फ़िगरेशन सहेजी गई",
"storage_migration": "डेटा माइग्रेशन",
"storage_migration_coming_soon": "माइग्रेशन टूल्स जल्द आ रहे हैं",
"migration_status_label": "माइग्रेशन स्थिति",
"migration_start": "माइग्रेशन शुरू करें",
"migration_pause": "रोकें",
"migration_resume": "फिर से शुरू करें",
"migration_verify": "सत्यापित करें",
"migration_complete": "पूर्ण करें",
"migration_started": "माइग्रेशन शुरू हुआ",
"migration_paused_msg": "माइग्रेशन रोका गया",
"migration_resumed_msg": "माइग्रेशन फिर से शुरू हुआ",
"migration_completed_msg": "माइग्रेशन सफलतापूर्वक पूर्ण हुआ",
"migration_verifying": "सत्यापन हो रहा है...",
"migration_verify_passed": "सत्यापन पास",
"migration_verify_failed": "सत्यापन विफल",
"migration_failed_blobs": "विफल ब्लॉब्स",
"testing": "परीक्षण हो रहा है...",
"smtp_disabled": "अक्षम (होस्ट सेट नहीं)",
"smtp_enabled": "सक्षम",
"smtp_enabled_label": "स्थिति",
"smtp_intro": "SMTP केवल पर्यावरण चर (OXICLOUD_SMTP_*) के माध्यम से कॉन्फ़िगर किया जाता है। नीचे दिए गए मान चल रहे सर्वर से पढ़े जाते हैं — उन्हें बदलने के लिए, पर्यावरण संपादित करें और OxiCloud को पुनः आरंभ करें।",
"smtp_not_configured": "इस सर्वर पर SMTP कॉन्फ़िगर नहीं है।",
"smtp_send_failed": "भेजना विफल।",
"smtp_send_test": "परीक्षण ईमेल भेजें",
"smtp_sending": "भेजा जा रहा है…",
"smtp_sent": "परीक्षण ईमेल भेजा गया।",
"smtp_server_code": "सर्वर का उत्तर",
"smtp_test_intro": "नीचे दिए गए प्राप्तकर्ता को एक पूर्व-निर्धारित निदान संदेश भेजता है और SMTP सर्वर का उत्तर रिपोर्ट करता है ताकि आप इसे अपने रिले लॉग्स से मिला सकें।",
"smtp_test_missing_to": "प्राप्तकर्ता पता दर्ज करें।",
"smtp_test_title": "परीक्षण ईमेल भेजें",
"smtp_test_to": "प्राप्तकर्ता का पता",
"smtp_title": "जावक ईमेल (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "प्रोफ़ाइल",
"back_to_app": "OxiCloud पर वापस",
"loading": "लोड हो रहा है…",
"not_authenticated": "प्रमाणित नहीं",
"not_authenticated_desc": "अपना प्रोफ़ाइल देखने के लिए साइन इन करें।",
"sign_in": "साइन इन",
"role_admin": "व्यवस्थापक",
"role_user": "उपयोगकर्ता",
"account_details": "खाता विवरण",
"username": "उपयोगकर्ता नाम",
"email": "ईमेल",
"role": "भूमिका",
"last_login": "अंतिम लॉगिन",
"storage": "स्टोरेज",
"used": "उपयोग किया",
"quota": "कोटा",
"usage": "उपयोग",
"unlimited": "असीमित",
"app_passwords": "ऐप पासवर्ड",
"app_pw_desc": "WebDAV, CalDAV और CardDAV क्लाइंट के लिए पासवर्ड जनरेट करें। प्रत्येक पासवर्ड केवल एक बार दिखाया जाता है।",
"app_pw_label_placeholder": "लेबल (जैसे Thunderbird, macOS)",
"generate": "जनरेट करें",
"generating": "जनरेट हो रहा है…",
"new_password_for": "नया पासवर्ड",
"copy_warning": "इस पासवर्ड को अभी कॉपी करें। आप इसे दोबारा नहीं देख पाएंगे।",
"copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें",
"col_label": "लेबल",
"col_created": "बनाया गया",
"col_last_used": "अंतिम उपयोग",
"col_status": "स्थिति",
"active": "सक्रिय",
"revoked": "रद्द",
"revoke_title": "रद्द करें",
"no_app_passwords": "अभी तक कोई ऐप पासवर्ड नहीं।",
"client_sessions": "क्लाइंट सत्र",
"client_sessions_desc": "Nextcloud-संगत क्लाइंट कनेक्ट करने पर स्वतः जनरेट।",
"col_client": "क्लाइंट",
"never": "कभी नहीं",
"just_now": "अभी",
"minutes_ago": "{{n}} मिनट पहले",
"hours_ago": "{{n}} घंटे पहले",
"days_ago": "{{n}} दिन पहले",
"edit_profile": "प्रोफ़ाइल संपादित करें",
"edit_oidc_managed": "अपनी जानकारी (नाम, प्रथम नाम, प्रोफ़ाइल चित्र, …) बदलने के लिए, कृपया अपने पहचान प्रदाता पर इसे अद्यतन करें। आपके परिवर्तन अगले साइन-इन पर दिखाई देंगे।",
"username_claim_hint": "2–64 अक्षर, अक्षर / अंक / डॉट / डैश / अंडरस्कोर। एक बार चुनने के बाद, उपयोगकर्ता नाम नहीं बदला जा सकता (DAV/NextCloud क्लाइंट इस पर निर्भर करते हैं)।",
"username_already_claimed": "उपयोगकर्ता नाम सेट है और बदला नहीं जा सकता (DAV/NextCloud क्लाइंट इस पर निर्भर करते हैं)।",
"given_name": "प्रथम नाम",
"family_name": "अंतिम नाम",
"notify_on_share": "जब कोई मेरे साथ साझा करे तो मुझे ईमेल भेजें",
"notify_on_share_hint": "जब अनचेक किया जाए, तो साझाकरण आपके खाते में दिखाई देते रहेंगे — आपको बस उनके बारे में ईमेल नहीं मिलेगा।",
"save_profile": "परिवर्तन सहेजें",
"profile_saved": "प्रोफ़ाइल अद्यतन की गई",
"profile_no_changes": "सहेजने के लिए कोई परिवर्तन नहीं।",
"profile_save_failed": "सहेजना विफल",
"username_taken_error": "यह उपयोगकर्ता नाम पहले से उपयोग में है।",
"username_immutable_error": "आपका उपयोगकर्ता नाम पहले से सेट है और यहाँ नहीं बदला जा सकता। यदि आपको नाम बदलने की आवश्यकता है तो किसी व्यवस्थापक से संपर्क करें।",
"change_password": "पासवर्ड बदलें",
"current_password": "वर्तमान पासवर्ड",
"new_password": "नया पासवर्ड",
"min_8_chars": "कम से कम 8 अक्षर",
"confirm_password": "नया पासवर्ड पुष्टि करें",
"update_password": "पासवर्ड अपडेट करें",
"updating": "अपडेट हो रहा है…",
"password_updated": "पासवर्ड सफलतापूर्वक अपडेट हुआ",
"passwords_no_match": "पासवर्ड मेल नहीं खाते",
"password_too_short": "पासवर्ड कम से कम 8 अक्षर का होना चाहिए",
"password_change_failed": "पासवर्ड बदलने में विफल",
"error_network": "नेटवर्क त्रुटि: {{message}}",
"error_label_required": "कृपया एक लेबल दर्ज करें",
"error_create_pw": "ऐप पासवर्ड बनाने में विफल",
"confirm_revoke": "ऐप पासवर्ड \"{{label}}\" रद्द करें? इसका उपयोग करने वाले क्लाइंट काम करना बंद कर देंगे।",
"error_revoke": "रद्द करने में विफल",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "अपलोड हो रहा है...",
"files": "फ़ाइलें",
"complete": "{{count}} / {{total}} अपलोड हुए"
},
"storage_quota_exceeded": "स्टोरेज कोटा पार हो गया",
"sharedwithme": {
"pageTitle": "मेरे साथ साझा किया",
"pageDescription": "फ़ाइलें और फ़ोल्डर जो अन्य उपयोगकर्ताओं ने आपके साथ साझा किए हैं",
"emptyStateTitle": "अभी तक आपके साथ कुछ भी साझा नहीं किया गया",
"emptyStateDesc": "अन्य उपयोगकर्ताओं द्वारा आपके साथ साझा किए गए आइटम यहाँ दिखाई देंगे",
"loadMore": "और लोड करें",
"sharedBy": "द्वारा साझा किया",
"colName": "नाम",
"colType": "प्रकार",
"colSharedBy": "द्वारा साझा किया",
"colDate": "साझाकरण तिथि",
"colPermissions": "अनुमतियाँ"
},
"groupby": {
"none": "कोई नहीं",
"title": "इसके अनुसार समूहीकृत करें",
"owner": "स्वामी",
"shareDate": "साझा तिथि",
"type": "प्रकार",
"type.folders": "फ़ोल्डर",
"accessedAt": "पहुँच की तारीख",
"modifiedAt": "संशोधन की तारीख",
"createdAt": "बनाने की तारीख",
"size": "आकार",
"favoriteDate": "पसंदीदा की तारीख",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "नया"
},
"dateBucket": {
"today": "आज",
"last7days": "पिछले 7 दिन",
"last30days": "पिछले 30 दिन"
},
"groups": {
"title": "समूह प्रबंधित करें",
"create_button": "समूह बनाएँ",
"create_dialog_title": "नया समूह",
"edit_dialog_title": "समूह का नाम बदलें",
"name_label": "नाम",
"name_placeholder": "engineering",
"description_label": "विवरण (वैकल्पिक)",
"members_section": "सदस्य",
"add_member_placeholder": "उपयोगकर्ता या समूह जोड़ें…",
"no_members": "अभी तक कोई सदस्य नहीं।",
"remove_member": "हटाएँ",
"delete_group": "समूह हटाएँ",
"delete_confirm": "समूह \"{name}\" को हटाएँ? इस समूह से जुड़ी अनुमतियाँ रद्द कर दी जाएँगी।",
"empty_state": "अभी तक कोई समूह नहीं।",
"load_more": "और लोड करें",
"back_to_list": "वापस",
"loading": "लोड हो रहा है…",
"virtual_badge": "सिस्टम",
"member_count_zero": "कोई सदस्य नहीं",
"member_count_one": "1 सदस्य",
"member_count_other": "{count} सदस्य",
"delete_confirm_label": "पुष्टि के लिए समूह का नाम लिखें:",
"delete_confirm_mismatch": "पुष्टि के लिए समूह का नाम बिल्कुल वैसा ही लिखें।",
"virtual_internal_name": "आंतरिक",
"members_loading": "सदस्य लोड हो रहे हैं…",
"members_empty": "कोई सदस्य नहीं",
"virtual_internal_explanation": "इस सर्वर पर हर आंतरिक उपयोगकर्ता"
},
"myshares": {
"copyLink": "लिंक कॉपी करें",
"deleteLink": "लिंक हटाएँ",
"notifyByEmail": "ईमेल से सूचित करें",
"notifyFailed": "सूचना नहीं भेजी जा सकी।",
"notifyGroupMembers": "समूह के सदस्यों को सूचित करें",
"notifyRateLimited": "इस प्राप्तकर्ता के लिए बहुत अधिक सूचनाएँ — बाद में पुनः प्रयास करें।",
"removeAccess": "पहुँच हटाएँ",
"resendInvitation": "आमंत्रण ईमेल पुनः भेजें"
},
"sort": {
"asc": "ascending",
"desc": "descending"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}
+980
View File
@@ -0,0 +1,980 @@
{
"server": {
"magic_link": {
"page": {
"expired_title": "Questo link di accesso non è più valido",
"expired_body": "Il link potrebbe essere scaduto o già stato utilizzato. Possiamo inviartene uno nuovo — arriverà nella tua casella di posta in pochi secondi.",
"resend_to": "Invia un nuovo link a {{email}}",
"generic_unavailable": "Questo link di accesso non è più valido. Potrebbe essere già stato utilizzato o essere scaduto. Richiedi un nuovo link dalla pagina di accesso.",
"service_unavailable": "L'accesso tramite magic link non è abilitato su questo server.",
"internal_error": "Si è verificato un errore durante l'accesso. Riprova.",
"resend_failure": "Si è verificato un errore durante l'invio del link. Riprova.",
"cross_browser_title": "Continuare l'accesso su questo dispositivo?",
"cross_browser_body": "Hai aperto questo link di accesso in un browser o dispositivo diverso da quello in cui l'hai richiesto.",
"cross_browser_warning": "Se hai richiesto questo link, puoi continuare in sicurezza. In caso contrario, chiudi questa pagina — cliccare su Continua effettuerebbe l'accesso di qualcun altro al tuo account.",
"cross_browser_continue": "Continua e accedi",
"resend_confirmation_title": "Controlla la tua casella di posta",
"resend_confirmation_body": "Se il link di accesso apparteneva a un account attivo, è appena stato inviato un nuovo link. Controlla la tua casella di posta.",
"return_link": "Torna a OxiCloud"
},
"email": {
"invitation": {
"subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud",
"body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nAprilo facendo clic sul link sottostante:\n{{link}}\n\nIl link è monouso e scade tra {{ttl_hours}} ore.\nSe non ti aspettavi questo invito, puoi ignorare questo messaggio.\n\n— OxiCloud"
},
"login": {
"subject": "Accedi a OxiCloud",
"body": "Ciao,\n\nUsa il link sottostante per accedere a OxiCloud. Il link è monouso e scade tra {{ttl_minutes}} minuti. Aprilo sullo stesso dispositivo da cui l'hai richiesto.\n\n{{link}}\n\nSe non hai richiesto questo link di accesso, puoi ignorare questo messaggio — non è necessaria alcuna ulteriore azione.\n\n— OxiCloud"
},
"kind_file": "file",
"kind_folder": "cartella",
"english_fallback_divider": "--- Versione inglese qui sotto ---"
}
},
"notification": {
"share": {
"subject": "{{inviter}} ha condiviso un {{kind}} con te su OxiCloud",
"body": "{{inviter_full}} ha condiviso un {{kind}} con te su OxiCloud.\n\nApri OxiCloud per vedere la tua nuova condivisione:\n{{login_link}}\n\nPotresti avere altre nuove condivisioni da {{inviter}} — accedi per vedere tutti gli elementi condivisi con te.\n\n— OxiCloud\n\nRicevi questo messaggio perché hai un account OxiCloud e la preferenza di notifica delle condivisioni è attiva. Puoi disattivarla dal tuo profilo (Avvisami via email quando qualcuno condivide con me)."
}
}
},
"app": {
"title": "OxiCloud",
"description": "Sistema di archiviazione cloud minimalista"
},
"nav": {
"files": "File",
"shared": "Condivisioni",
"recent": "Recenti",
"favorites": "Preferiti",
"photos": "Foto",
"music": "Musica",
"trash": "Cestino",
"sharedwithme": "Condivisi con me"
},
"photos": {
"empty_state": "Nessuna foto ancora",
"empty_hint": "Carica immagini o video per vederli qui",
"items_selected": "selezionati",
"view_daily": "Giorno",
"view_monthly": "Mese",
"view_yearly": "Anno"
},
"music": {
"create_playlist": "Crea Playlist",
"playlists": "Playlist",
"no_playlists": "Nessuna playlist",
"select_playlist": "Seleziona una playlist",
"select_hint": "Scegli una playlist dalla barra laterale o creane una nuova",
"add_tracks": "Aggiungi Tracce",
"no_tracks": "Nessuna traccia in questa playlist",
"unknown_artist": "Artista Sconosciuto",
"unknown_title": "Sconosciuto",
"confirm_delete": "Eliminare questa playlist?",
"playlist_name": "Nome playlist",
"create": "Crea",
"delete": "Elimina",
"share": "Condividi",
"edit": "Modifica",
"play_all": "Riproduci Tutto",
"shuffle": "Casuale",
"repeat": "Ripeti",
"repeat_one": "Ripeti Una",
"queue": "Coda",
"queue_empty": "Coda vuota",
"not_playing": "Non in riproduzione",
"play": "Riproduci",
"pause": "Pausa",
"previous": "Precedente",
"next": "Successivo",
"volume": "Volume",
"mute": "Muto",
"unmute": "Attiva audio",
"title": "Titolo",
"artist": "Artista",
"album": "Album",
"tracks": "tracce",
"add": "Aggiungi",
"added": "Aggiunto!",
"added_to_playlist": "aggiunto alla playlist",
"add_to_playlist": "Aggiungi alla playlist",
"load_error": "Errore nel caricamento delle playlist",
"add_error": "Impossibile aggiungere le tracce",
"no_playlists_yet": "Nessuna playlist ancora. Creane una prima!",
"selected_files": "Selezionati:",
"error": "Errore",
"search_audio": "Cerca file audio…",
"no_audio_files": "Nessun file audio trovato",
"selected": "selezionati",
"loading": "Caricamento…",
"search_error": "Impossibile caricare i file audio",
"adding": "Aggiunta in corso…",
"can_write": "Can edit",
"cover_updated": "Cover updated",
"empty_hint": "Create your first playlist to start organizing your music",
"make_private": "Make private",
"make_public": "Make public",
"manage_shares": "Manage Shares",
"no_shares": "No shares yet",
"playback_error": "Playback failed",
"private": "Private",
"public": "Public",
"read_only": "Read only",
"remove": "Remove",
"remove_share": "Remove share",
"set_cover": "Set cover",
"share_with_user": "User ID or email",
"toggle_public": "Visibility",
"track_removed": "Track removed"
},
"actions": {
"search": "Cerca file...",
"new_folder": "Nuova cartella",
"upload": "Carica",
"upload_files": "Carica file",
"upload_folder": "Carica cartella",
"upload.uploading": "Caricamento...",
"upload.complete": "{count} / {total} caricati",
"upload.files": "file",
"rename": "Rinomina",
"move": "Sposta in...",
"move_to": "Sposta in",
"delete": "Elimina",
"download": "Scarica",
"view": "Visualizza",
"cancel": "Annulla",
"confirm": "Conferma",
"share": "Condividi",
"favorite": "Aggiungi ai preferiti",
"unfavorite": "Rimuovi dai preferiti",
"copy": "Copia",
"notify": "Notifica",
"send": "Invia",
"clear_recent": "Cancella recenti",
"logout": "Disconnetti",
"create": "Crea",
"search_btn": "Cerca",
"close": "Chiudi",
"delete_permanently": "Elimina definitivamente",
"empty_trash": "Svuota il cestino",
"open_parent_folder": "Vai alla cartella padre",
"add": "Add",
"apply": "Apply",
"clear": "Clear",
"remove": "Remove"
},
"user_menu": {
"appearance": "Aspetto",
"about": "Informazioni su OxiCloud",
"about_description": "Piattaforma di archiviazione cloud realizzata con Rust & Architettura Pulita. Veloce, sicura e privata.",
"admin_panel": "Pannello di amministrazione",
"profile": "Il mio profilo",
"role_user": "Utente",
"theme": {
"light": "Chiaro",
"dark": "Scuro",
"auto": "Come il sistema"
},
"manage_groups": "Gestisci gruppi"
},
"share": {
"dialogTitle": "Link di condivisione",
"linkLabel": "Link di condivisione:",
"copyLink": "Copia",
"permissions": "Permessi:",
"permissionRead": "Lettura",
"permissionWrite": "Scrittura",
"permissionReshare": "Ricondivisione",
"password": "Protezione password:",
"generatePassword": "Genera",
"expiration": "Data di scadenza:",
"update": "Aggiorna condivisione",
"remove": "Rimuovi condivisione",
"notifyTitle": "Invia notifica",
"notifyEmailLabel": "Indirizzo email:",
"notifyMessageLabel": "Messaggio (opzionale):",
"notifySend": "Invia notifica",
"shareWithOthers": "Condividi con altri",
"sharePublicly": "Condividi pubblicamente",
"shareSettings": "Impostazioni di condivisione",
"shareCopied": "Link copiato negli appunti",
"shareCreated": "Link di condivisione creato con successo",
"shareUpdated": "Impostazioni di condivisione aggiornate con successo",
"shareRemoved": "Condivisione rimossa con successo",
"inviteByEmail": "Invita via email — verrà inviato un invito",
"directoryUnavailable": "User directory unavailable",
"linkNamePlaceholder": "Link name (optional)",
"newLink": "New link",
"noExpiry": "No expiry",
"pending": "Pending",
"people": "People",
"publicLinks": "Public links",
"role": {
"canEdit": "Can edit",
"canManage": "Can manage",
"canView": "Can view"
},
"searchPlaceholder": "Search people…",
"shareOf": "Share of:",
"sharedLink": "Shared link"
},
"share_dialogTitle": "Link di condivisione",
"share_linkLabel": "Link di condivisione:",
"share_copyLink": "Copia",
"share_permissions": "Permessi:",
"share_permissionRead": "Lettura",
"share_permissionWrite": "Scrittura",
"share_permissionReshare": "Ricondivisione",
"share_password": "Protezione password:",
"share_generatePassword": "Genera",
"share_expiration": "Data di scadenza:",
"share_update": "Aggiorna condivisione",
"share_remove": "Rimuovi condivisione",
"share_notifyTitle": "Invia notifica",
"share_notifyEmailLabel": "Indirizzo email:",
"share_notifyMessageLabel": "Messaggio (opzionale):",
"share_notifySend": "Invia notifica",
"shared": {
"backToFiles": "Torna ai file",
"pageTitle": "Risorse condivise",
"pageDescription": "Gestisci i tuoi file e le tue cartelle condivise",
"filterType": "Tipo:",
"filterAll": "Tutti",
"filterFiles": "File",
"filterFolders": "Cartelle",
"sortBy": "Ordina per:",
"sortByName": "Nome",
"sortByDate": "Data di condivisione",
"sortByExpiration": "Scadenza",
"search": "Cerca",
"colName": "Nome",
"colType": "Tipo",
"colDateShared": "Data di condivisione",
"colExpiration": "Scadenza",
"colPermissions": "Permessi",
"colPassword": "Password",
"colActions": "Azioni",
"emptyStateTitle": "Ancora nessuna risorsa condivisa",
"emptyStateDesc": "Quando condividi file o cartelle, appariranno qui",
"goToFiles": "Vai ai file",
"typeFile": "File",
"typeFolder": "Cartella",
"noExpiration": "Nessuna scadenza",
"hasPassword": "Sì",
"noPassword": "No",
"editShare": "Modifica condivisione",
"notifyShare": "Notifica a qualcuno",
"copyLink": "Copia link",
"removeShare": "Rimuovi condivisione",
"linkCopied": "Link copiato negli appunti!",
"linkCopyFailed": "Impossibile copiare il link",
"itemUpdated": "Impostazioni di condivisione aggiornate con successo",
"itemRemoved": "Condivisione rimossa con successo",
"invalidEmail": "Inserisci un indirizzo email valido",
"notificationSent": "Notifica inviata con successo",
"notificationFailed": "Impossibile inviare la notifica",
"shared_backToFiles": "Torna ai file",
"shared_pageTitle": "Risorse condivise",
"shared_pageDescription": "Gestisci i tuoi file e le tue cartelle condivise",
"shared_filterType": "Tipo:",
"shared_filterAll": "Tutti",
"shared_filterFiles": "File",
"shared_filterFolders": "Cartelle",
"shared_sortBy": "Ordina per:",
"shared_sortByName": "Nome",
"shared_sortByDate": "Data di condivisione",
"shared_sortByExpiration": "Scadenza",
"shared_search": "Cerca",
"shared_colName": "Nome",
"shared_colType": "Tipo",
"shared_colDateShared": "Data di condivisione",
"shared_colExpiration": "Scadenza",
"shared_colPermissions": "Permessi",
"shared_colPassword": "Password",
"shared_colActions": "Azioni",
"shared_emptyStateTitle": "Ancora nessuna risorsa condivisa",
"shared_emptyStateDesc": "Quando condividi file o cartelle, appariranno qui",
"shared_goToFiles": "Vai ai file",
"shared_typeFile": "File",
"shared_typeFolder": "Cartella",
"shared_noExpiration": "Nessuna scadenza",
"shared_hasPassword": "Sì",
"shared_noPassword": "No",
"shared_editShare": "Modifica condivisione",
"shared_notifyShare": "Notifica a qualcuno",
"shared_copyLink": "Copia link",
"shared_removeShare": "Rimuovi condivisione",
"shared_linkCopied": "Link copiato negli appunti!",
"shared_linkCopyFailed": "Impossibile copiare il link",
"shared_itemUpdated": "Impostazioni di condivisione aggiornate con successo",
"shared_itemRemoved": "Condivisione rimossa con successo",
"shared_invalidEmail": "Inserisci un indirizzo email valido",
"shared_notificationSent": "Notifica inviata con successo",
"shared_notificationFailed": "Impossibile inviare la notifica"
},
"files": {
"name": "Nome",
"type": "Tipo",
"size": "Dimensione",
"modified": "Modificato",
"no_files": "Nessun file in questa cartella",
"empty_hint": "Carica file o crea cartelle per iniziare",
"loading": "Caricamento file…",
"view_grid": "Visualizzazione griglia",
"view_list": "Visualizzazione elenco",
"file_types": {
"document": "Documento",
"image": "Immagine",
"video": "Video",
"audio": "Audio",
"pdf": "PDF",
"text": "Testo",
"folder": "Cartella",
"spreadsheet": "Foglio di calcolo",
"presentation": "Presentazione",
"archive": "Archivio",
"installer": "Programma di installazione",
"code": "Codice"
},
"owner": "Proprietario"
},
"dialogs": {
"rename_folder": "Rinomina cartella",
"rename_file": "Rinomina file",
"new_name": "Nuovo nome",
"new_folder_title": "Nuova cartella",
"folder_name": "Nome cartella",
"folder_placeholder": "La mia cartella",
"rename_title": "Rinomina",
"move_file": "Sposta file",
"move_folder": "Sposta cartella",
"select_destination": "Seleziona cartella di destinazione:",
"root": "Root",
"delete_confirmation": "Sei sicuro di voler eliminare",
"and_contents": "e tutto il suo contenuto",
"no_undo": "Questa azione non può essere annullata",
"confirm_title": "Conferma azione",
"confirm_delete": "Sposta nel cestino",
"confirm_delete_file": "Sei sicuro di voler spostare il file \"{{name}}\" nel cestino?",
"confirm_delete_folder": "Sei sicuro di voler spostare la cartella \"{{name}}\" e tutto il suo contenuto nel cestino?",
"confirm_permanent_delete": "Elimina definitivamente",
"confirm_permanent_delete_msg": "Sei sicuro di voler eliminare definitivamente questo elemento? Questa azione non può essere annullata.",
"confirm_empty_trash": "Svuota il cestino",
"confirm_delete_share": "Elimina link di condivisione",
"confirm_delete_share_msg": "Sei sicuro di voler eliminare questo link di condivisione?",
"share_file": "Condividi File",
"share_folder": "Condividi Cartella",
"existing_shares": "Condivisioni Esistenti",
"share_options": "Opzioni di Condivisione",
"password": "Password",
"expiration": "Scadenza",
"permissions": "Permessi",
"generated_link": "Link Generato",
"notify": "Invia Notifica",
"recipient": "Destinatario",
"message": "Messaggio",
"go_to_parent": ".. (parent folder)",
"no_subfolders": "No subfolders",
"select_this_folder": "Select this folder",
"move_to_home": "Sposta nella cartella home"
},
"dropzone": {
"drag_files": "Trascina i file qui o clicca per selezionare",
"drop_files": "Rilascia i file per caricarli"
},
"permissions": {
"read": "Lettura",
"write": "Scrittura",
"reshare": "Ricondividi"
},
"errors": {
"file_not_found": "File non trovato",
"folder_not_found": "Cartella non trovata",
"delete_error": "Errore durante l'eliminazione",
"upload_error": "Errore durante il caricamento del file",
"rename_error": "Errore durante la rinomina",
"move_error": "Errore durante lo spostamento",
"empty_name": "Il nome non può essere vuoto",
"name_exists": "Un file o una cartella con quel nome esiste già",
"generic_error": "Si è verificato un errore",
"group_name_invalid": "Il nome del gruppo deve rispettare il formato del prefisso email (lettere, cifre, punto, trattino, trattino basso; 1–64 caratteri).",
"group_cycle": "Questo membro creerebbe un riferimento circolare tra gruppi.",
"group_depth_exceeded": "Questa profondità di annidamento supera il massimo consentito (8).",
"group_virtual_immutable": "Il gruppo «Internal» è gestito dal sistema e non può essere modificato.",
"group_not_found": "Gruppo non trovato.",
"group_name_taken": "Un gruppo con questo nome esiste già."
},
"breadcrumb": {
"home": "Home"
},
"trash": {
"empty_trash": "Svuota il cestino",
"empty_state": "Il cestino è vuoto",
"original_location": "Posizione originale",
"deleted_date": "Data di eliminazione",
"remaining": "Rimanente",
"actions": "Azioni",
"restore": "Ripristina",
"delete_permanently": "Elimina definitivamente",
"empty_confirm": "Sei sicuro di voler svuotare il cestino? Questa operazione eliminerà definitivamente tutti gli elementi.",
"groupby": {
"remaining_days": "Giorni rimanenti",
"trashed_time": "Data di eliminazione"
}
},
"daysRemaining": {
"expired": "Scaduto",
"today": "Oggi",
"tomorrow": "Domani",
"inDays": "{{count}} giorni"
},
"expiryChip": {
"never": "Non scade mai",
"expired": "Scaduto",
"today": "Scade oggi",
"tomorrow": "Scade domani",
"inDays": "Scade tra {{count}} giorni",
"onDate": "Scade il {{date}}"
},
"auth": {
"login_title": "Accedi",
"username": "Nome utente",
"username_placeholder": "Inserisci il tuo nome utente",
"login_identifier": "Nome utente o email",
"login_identifier_placeholder": "Inserisci il tuo nome utente o email",
"password": "Password",
"password_placeholder": "Inserisci la tua password",
"login_button": "Accedi",
"no_account": "Non hai un account?",
"register": "Registrati",
"admin_setup": "È la prima volta?",
"setup": "Configura amministratore",
"register_title": "Crea account",
"email": "Email",
"email_placeholder": "Inserisci la tua email",
"confirm_password": "Conferma password",
"confirm_password_placeholder": "Conferma la tua password",
"register_button": "Crea account",
"have_account": "Hai già un account?",
"login": "Accedi",
"setup_title": "Configurazione iniziale",
"setup_step1": "Amministratore",
"setup_step2": "Sistema",
"setup_step3": "Completa",
"admin_username": "Nome utente amministratore",
"admin_email": "Email amministratore",
"admin_password": "Password amministratore",
"create_admin": "Crea amministratore",
"back_to_login": "Già configurato?",
"admin_success": "Account amministratore creato con successo! Ora puoi accedere.",
"account_success": "Account creato con successo! Ora puoi accedere.",
"passwords_mismatch": "Le password non corrispondono",
"admin_create_error": "Errore durante la creazione dell'account amministratore",
"or": "o",
"sso_login": "Accedi con SSO",
"sso_login_provider": "Accedi con {{provider}}",
"magicLinkHint": "Niente password? Inserisci la tua email e ti invieremo un link di accesso monouso.",
"magicLinkEmailLabel": "Indirizzo email",
"magicLinkEmailPlaceholder": "tu@esempio.com",
"magicLinkSubmit": "Invia link di accesso",
"magicLinkSent": "Se esiste un account per questa email, è stato inviato un link di accesso. Controlla la tua casella di posta.",
"magicLinkUnavailable": "L'accesso tramite email non è disponibile su questo server.",
"magicLinkNetworkError": "Impossibile raggiungere il server: {{message}}",
"magicLinkToggle": "Nessuna password? Ricevi un link via e-mail",
"passwordsMatch": "Le password corrispondono",
"capsLock": "Bloc Maiusc attivo"
},
"storage": {
"title": "Archiviazione",
"calculating": "Calcolo in corso...",
"used": "{{percentage}}% utilizzato ({{used}} / {{total}})"
},
"viewer": {
"unsupported_file": "Questo tipo di file non può essere visualizzato in anteprima.",
"download_file": "Scarica file",
"zoom_in": "Ingrandisci",
"zoom_out": "Riduci",
"zoom_reset": "Reimposta zoom"
},
"language_selector": {
"title": "Benvenuto!",
"subtitle": "Seleziona la tua lingua per continuare",
"continue": "Continua",
"languages": {
"en": "Inglese",
"es": "Spagnolo",
"zh": "Cinese",
"fa": "Persiano",
"fr": "Francese",
"de": "Tedesco",
"pt": "Portoghese",
"it": "Italiano",
"ar": "العربية",
"hi": "हिन्दी",
"ja": "日本語",
"ko": "한국어",
"nl": "Nederlands",
"ru": "Русский"
}
},
"favorites": {
"empty_state": "Ancora nessun preferito",
"empty_hint": "Aggiungi file o cartelle ai preferiti per inserirli qui",
"add": "Aggiungi ai preferiti",
"remove": "Rimuovi dai preferiti",
"added_title": "Aggiunto ai preferiti",
"added_msg": "aggiunto ai preferiti",
"removed_title": "Rimosso dai preferiti",
"removed_msg": "rimosso dai preferiti"
},
"recent": {
"title": "Recenti",
"clear": "Cancella recenti",
"accessed": "Accesso",
"empty_state": "Nessun file recente",
"empty_hint": "I file che apri appariranno qui",
"loadMore": "Carica altri"
},
"notifications": {
"file_renamed": "File rinominato",
"file_renamed_to": "File rinominato in \"{{name}}\"",
"folder_renamed": "Cartella rinominata",
"folder_renamed_to": "Cartella rinominata in \"{{name}}\"",
"file_uploaded": "File caricato",
"file_deleted": "File spostato nel cestino",
"folder_deleted": "Cartella spostata nel cestino",
"item_deleted_permanently": "Elemento eliminato definitivamente",
"trash_emptied": "Cestino svuotato con successo",
"empty": "No notifications",
"title": "Notifications",
"link_created": "Link creato",
"share_success": "Link di condivisione creato con successo",
"upload_files_section_title": "Caricamento non disponibile qui",
"upload_files_section_body": "Vai alla sezione File per caricare i file"
},
"batch": {
"one_selected": "1 elemento selezionato",
"n_selected": "{{count}} elementi selezionati",
"confirm_delete": "Sei sicuro di voler spostare {{count}} elementi nel cestino?",
"move_title": "Sposta {{count}} elemento/i",
"add_favorites": "Aggiungi ai preferiti",
"move_copy": "Sposta o copia"
},
"admin": {
"page_title": "Pannello di Amministrazione",
"back_to_app": "Torna a OxiCloud",
"loading": "Caricamento…",
"access_denied": "Accesso Negato",
"access_denied_desc": "Privilegi di amministratore necessari.",
"sign_in": "Accedi",
"tab_dashboard": "Dashboard",
"tab_users": "Utenti",
"tab_oidc": "SSO / OIDC",
"total_users": "Utenti Totali",
"active_users": "Utenti Attivi",
"admins": "Amministratori",
"version": "Versione",
"storage_overview": "Panoramica Archiviazione",
"used": "Usato",
"total_quota": "Quota Totale",
"usage_pct": "Utilizzo %",
"users_over_80": "Utenti >80% quota",
"users_over_quota": "Utenti oltre la quota",
"system": "Sistema",
"auth_label": "Auth",
"oidc_label": "OIDC",
"quotas_label": "Quote",
"enabled": "Abilitato",
"disabled": "Disabilitato",
"active": "Attivo",
"off": "Spento",
"allow_registration": "Consenti registrazione pubblica",
"registration_warning": "La registrazione pubblica è disabilitata. Solo gli admin possono creare utenti.",
"user_management": "Gestione Utenti",
"create_user": "Crea Utente",
"col_user": "Utente",
"col_role": "Ruolo",
"col_auth": "Auth",
"col_status": "Stato",
"col_storage": "Archiviazione",
"col_last_login": "Ultimo Accesso",
"col_actions": "Azioni",
"loading_users": "Caricamento utenti…",
"failed_load_users": "Impossibile caricare",
"no_users_found": "Nessun utente trovato",
"showing_users": "Mostrando {{from}}-{{to}} di {{total}}",
"prev": "Precedente",
"next": "Successivo",
"inactive": "Inattivo",
"you_badge": "(tu)",
"local": "Locale",
"never": "Mai",
"just_now": "Proprio adesso",
"minutes_ago": "{{n}}min fa",
"hours_ago": "{{n}}h fa",
"days_ago": "{{n}}g fa",
"edit_quota_title": "Modifica quota",
"reset_password_title": "Reimposta password",
"toggle_role_title": "Cambia ruolo",
"deactivate_title": "Disattiva",
"activate_title": "Attiva",
"delete_title": "Elimina",
"sso_title": "Single Sign-On (OIDC / SSO)",
"enable_sso": "Abilita autenticazione SSO",
"provider_name": "Nome Provider",
"issuer_url": "URL Emittente",
"issuer_url_hint": "URL dell'emittente OpenID Connect",
"auto_discover": "Auto-scoperta",
"discovering": "Scoperta…",
"client_id": "Client ID",
"client_secret": "Client Secret",
"client_secret_placeholder": "Lascia vuoto per mantenere il valore",
"secret_configured": "Un client secret è già configurato",
"callback_url": "URL di Callback",
"callback_url_hint": "(registra nel tuo IdP)",
"advanced_settings": "Impostazioni Avanzate",
"scopes": "Scopes",
"auto_provision": "Provisioning automatico degli utenti",
"admin_groups": "Gruppi Admin",
"admin_groups_hint": "Nomi di gruppi OIDC separati da virgola",
"disable_password": "Disabilita accesso con password (solo OIDC)",
"password_warning": "Questo impedirà TUTTI gli accessi tramite password!",
"test_btn": "Test",
"save_btn": "Salva",
"saving": "Salvataggio…",
"settings_saved": "Impostazioni salvate — OIDC ora è {{status}}",
"quota_modal_title": "Aggiorna Quota",
"quota_user_label": "Utente:",
"new_quota": "Nuova Quota",
"quota_unlimited_hint": "0 per illimitato",
"cancel": "Annulla",
"create_user_title": "Crea Nuovo Utente",
"username_label": "Nome utente",
"username_placeholder": "mariorossi",
"username_hint": "3–32 caratteri",
"password_label": "Password",
"password_placeholder": "Min 8 caratteri",
"email_label": "Email",
"email_optional": "(facoltativo)",
"email_placeholder": "utente@esempio.com (auto-generata se vuoto)",
"role_label": "Ruolo",
"role_user": "Utente",
"role_admin": "Admin",
"quota_label": "Quota",
"creating": "Creazione…",
"reset_pw_title": "Reimposta Password",
"new_password_label": "Nuova Password",
"resetting": "Reimpostazione…",
"reset_btn": "Reimposta",
"confirm_role_change": "Cambiare ruolo a {{role}}?",
"confirm_deactivate": "Sei sicuro di voler disattivare questo utente?",
"confirm_activate": "Sei sicuro di voler attivare questo utente?",
"confirm_delete_user": "ELIMINARE l'utente \"{{name}}\"? Azione irreversibile!",
"confirm_action": "Conferma Azione",
"confirm_yes": "Conferma",
"confirm_no": "Annulla",
"error_username_short": "Il nome utente deve avere almeno 3 caratteri",
"error_password_short": "La password deve avere almeno 8 caratteri",
"error_generic": "Fallito",
"error_network": "Errore di rete: {{message}}",
"error_create_user": "Impossibile creare l'utente",
"tab_storage": "Archiviazione",
"storage_title": "Configurazione archiviazione",
"storage_current_backend": "Backend corrente",
"storage_total_blobs": "Blob totali",
"storage_total_size": "Dimensione totale",
"storage_dedup_ratio": "Rapporto deduplicazione",
"storage_backend": "Backend",
"storage_local": "Locale",
"storage_s3": "Compatibile S3",
"storage_provider_preset": "Preset fornitore",
"storage_preset_custom": "Personalizzato",
"storage_endpoint_url": "URL endpoint",
"storage_endpoint_hint": "Lasciare vuoto per AWS S3",
"storage_bucket": "Bucket",
"storage_region": "Regione",
"storage_access_key": "Chiave di accesso",
"storage_secret_key": "Chiave segreta",
"storage_secret_configured": "Chiave configurata",
"storage_key_placeholder": "Inserisci nuova chiave",
"storage_path_style": "Forza stile percorso",
"storage_path_style_hint": "Richiesto per MinIO e alcuni servizi compatibili S3",
"storage_test_connection": "Testa connessione",
"storage_test_success": "Connessione riuscita",
"storage_test_failure": "Connessione fallita",
"storage_save": "Salva configurazione",
"storage_saved": "Configurazione salvata",
"storage_migration": "Migrazione dati",
"storage_migration_coming_soon": "Strumenti di migrazione in arrivo",
"migration_status_label": "Stato migrazione",
"migration_start": "Avvia migrazione",
"migration_pause": "Pausa",
"migration_resume": "Riprendi",
"migration_verify": "Verifica",
"migration_complete": "Completa",
"migration_started": "Migrazione avviata",
"migration_paused_msg": "Migrazione in pausa",
"migration_resumed_msg": "Migrazione ripresa",
"migration_completed_msg": "Migrazione completata con successo",
"migration_verifying": "Verifica in corso...",
"migration_verify_passed": "Verifica superata",
"migration_verify_failed": "Verifica fallita",
"migration_failed_blobs": "Blob falliti",
"testing": "Test in corso...",
"smtp_disabled": "Disabilitato (host non impostato)",
"smtp_enabled": "Abilitato",
"smtp_enabled_label": "Stato",
"smtp_intro": "SMTP è configurato esclusivamente tramite variabili d'ambiente (OXICLOUD_SMTP_*). I valori sottostanti sono letti dal server in esecuzione — per modificarli, modifica l'ambiente e riavvia OxiCloud.",
"smtp_not_configured": "SMTP non è configurato su questo server.",
"smtp_send_failed": "Invio non riuscito.",
"smtp_send_test": "Invia email di prova",
"smtp_sending": "Invio in corso…",
"smtp_sent": "Email di prova inviata.",
"smtp_server_code": "Risposta del server",
"smtp_test_intro": "Invia un messaggio diagnostico predefinito al destinatario indicato sotto e riporta la risposta del server SMTP, così puoi correlarla con i log del tuo relay.",
"smtp_test_missing_to": "Inserisci un indirizzo destinatario.",
"smtp_test_title": "Invia un'email di prova",
"smtp_test_to": "Indirizzo destinatario",
"smtp_title": "Email in uscita (SMTP)",
"tab_smtp": "SMTP"
},
"profile": {
"page_title": "Profilo",
"back_to_app": "Torna a OxiCloud",
"loading": "Caricamento…",
"not_authenticated": "Non Autenticato",
"not_authenticated_desc": "Accedi per visualizzare il tuo profilo.",
"sign_in": "Accedi",
"role_admin": "Amministratore",
"role_user": "Utente",
"account_details": "Dettagli Account",
"username": "Nome utente",
"email": "Email",
"role": "Ruolo",
"last_login": "Ultimo accesso",
"storage": "Archiviazione",
"used": "Usato",
"quota": "Quota",
"usage": "Utilizzo",
"unlimited": "Illimitato",
"app_passwords": "Password Applicazione",
"app_pw_desc": "Genera password per client WebDAV, CalDAV e CardDAV. Ogni password viene mostrata una sola volta.",
"app_pw_label_placeholder": "Etichetta (es. Thunderbird, macOS)",
"generate": "Genera",
"generating": "Generazione…",
"new_password_for": "Nuova password per",
"copy_warning": "Copia questa password ora. Non potrai rivederla.",
"copy_to_clipboard": "Copia negli appunti",
"col_label": "Etichetta",
"col_created": "Creato",
"col_last_used": "Ultimo utilizzo",
"col_status": "Stato",
"active": "Attiva",
"revoked": "Revocata",
"revoke_title": "Revoca",
"no_app_passwords": "Nessuna password applicazione ancora.",
"client_sessions": "Sessioni client",
"client_sessions_desc": "Generate automaticamente quando connetti un client compatibile Nextcloud.",
"col_client": "Client",
"never": "Mai",
"just_now": "Proprio adesso",
"minutes_ago": "{{n}} min fa",
"hours_ago": "{{n}}h fa",
"days_ago": "{{n}} giorni fa",
"edit_profile": "Modifica profilo",
"edit_oidc_managed": "Per modificare le tue informazioni (nome, cognome, foto profilo, …), aggiornale presso il tuo identity provider. Le modifiche compariranno al prossimo accesso.",
"username_claim_hint": "Da 2 a 64 caratteri, lettere / cifre / punto / trattino / sottolineatura. Una volta scelto, il nome utente non può essere modificato (i client DAV/NextCloud dipendono da esso).",
"username_already_claimed": "Nome utente impostato e non modificabile (i client DAV/NextCloud dipendono da esso).",
"given_name": "Nome",
"family_name": "Cognome",
"notify_on_share": "Avvisami via email quando qualcuno condivide con me",
"notify_on_share_hint": "Se deselezionato, le condivisioni continueranno ad apparire nel tuo account — semplicemente non riceverai un'email a riguardo.",
"save_profile": "Salva modifiche",
"profile_saved": "Profilo aggiornato",
"profile_no_changes": "Nessuna modifica da salvare.",
"profile_save_failed": "Salvataggio non riuscito",
"username_taken_error": "Questo nome utente è già in uso.",
"username_immutable_error": "Il tuo nome utente è già impostato e non può essere cambiato qui. Contatta un amministratore se desideri rinominarlo.",
"change_password": "Cambia Password",
"current_password": "Password Attuale",
"new_password": "Nuova Password",
"min_8_chars": "Almeno 8 caratteri",
"confirm_password": "Conferma Nuova Password",
"update_password": "Aggiorna Password",
"updating": "Aggiornamento…",
"password_updated": "Password aggiornata con successo",
"passwords_no_match": "Le password non corrispondono",
"password_too_short": "La password deve avere almeno 8 caratteri",
"password_change_failed": "Impossibile cambiare la password",
"error_network": "Errore di rete: {{message}}",
"error_label_required": "Inserisci un'etichetta",
"error_create_pw": "Impossibile creare la password",
"confirm_revoke": "Revocare la password \"{{label}}\"? I client che la usano smetteranno di funzionare.",
"error_revoke": "Revoca fallita",
"edit_photo": "Edit photo",
"photo_tab_url": "URL",
"photo_tab_upload": "Upload",
"photo_url_placeholder": "https://example.com/photo.jpg",
"photo_url_hint": "https://, http://, or data:image/…;base64,… accepted",
"photo_choose_file": "Choose a photo (PNG, JPEG, WebP)",
"photo_resize_note": "Images larger than 512 × 512 px are automatically resized.",
"photo_save": "Save photo",
"photo_remove": "Remove photo",
"photo_cancel": "Cancel",
"photo_save_failed": "Failed to save photo",
"photo_no_file": "Please select a file first",
"photo_managed_by_oidc": "Photo managed by your identity provider."
},
"upload": {
"uploading": "Caricamento in corso...",
"files": "file",
"complete": "{{count}} / {{total}} caricati"
},
"storage_quota_exceeded": "Quota di archiviazione superata",
"sharedwithme": {
"pageTitle": "Condiviso con me",
"pageDescription": "File e cartelle che altri utenti hanno condiviso con te",
"emptyStateTitle": "Niente è ancora condiviso con te",
"emptyStateDesc": "Gli elementi condivisi con te da altri utenti appariranno qui",
"loadMore": "Carica altri",
"sharedBy": "Condiviso da",
"colName": "Nome",
"colType": "Tipo",
"colSharedBy": "Condiviso da",
"colDate": "Data condivisione",
"colPermissions": "Permessi"
},
"groupby": {
"none": "Nessuno",
"title": "Raggruppa per",
"owner": "Proprietario",
"shareDate": "Data condivisione",
"type": "Tipo",
"type.folders": "Cartelle",
"accessedAt": "Data di accesso",
"modifiedAt": "Data di modifica",
"createdAt": "Data di creazione",
"size": "Dimensione",
"favoriteDate": "Data preferito",
"byFiles": "By files",
"sharedWith": "Shared with",
"justAdded": "Nuovo"
},
"dateBucket": {
"today": "Oggi",
"last7days": "Ultimi 7 giorni",
"last30days": "Ultimi 30 giorni"
},
"groups": {
"title": "Gestisci gruppi",
"create_button": "Crea gruppo",
"create_dialog_title": "Nuovo gruppo",
"edit_dialog_title": "Rinomina gruppo",
"name_label": "Nome",
"name_placeholder": "ingegneria",
"description_label": "Descrizione (opzionale)",
"members_section": "Membri",
"add_member_placeholder": "Aggiungi un utente o un gruppo…",
"no_members": "Nessun membro al momento.",
"remove_member": "Rimuovi",
"delete_group": "Elimina gruppo",
"delete_confirm": "Eliminare il gruppo \"{name}\"? Le autorizzazioni che fanno riferimento a questo gruppo saranno revocate.",
"empty_state": "Nessun gruppo al momento.",
"load_more": "Carica altro",
"back_to_list": "Indietro",
"loading": "Caricamento…",
"virtual_badge": "Sistema",
"member_count_zero": "Nessun membro",
"member_count_one": "1 membro",
"member_count_other": "{count} membri",
"delete_confirm_label": "Digita il nome del gruppo per confermare:",
"delete_confirm_mismatch": "Digita esattamente il nome del gruppo per confermare.",
"virtual_internal_name": "Interno",
"members_loading": "Caricamento membri…",
"members_empty": "Nessun membro",
"virtual_internal_explanation": "Ogni utente interno su questo server"
},
"myshares": {
"copyLink": "Copia link",
"deleteLink": "Elimina link",
"notifyByEmail": "Notifica via email",
"notifyFailed": "Impossibile inviare la notifica.",
"notifyGroupMembers": "Notifica i membri del gruppo",
"notifyRateLimited": "Troppe notifiche per questo destinatario — riprova più tardi.",
"removeAccess": "Rimuovi accesso",
"resendInvitation": "Reinvia email di invito"
},
"sort": {
"asc": "crescente",
"desc": "decrescente"
},
"notif": {
"errorTitle": "Error",
"searchError": "Error performing search",
"cleanupCompleted": "Cleanup completed",
"cleanupCompletedBody": "Recent files history has been cleared",
"batchCopy": "Batch copy",
"batchCopyBody": "{{success}} copied, {{errors}} failed",
"itemsCopied": "Items copied",
"itemsCopiedBody": "{{count}} items copied successfully",
"batchMove": "Batch move",
"batchMoveBody": "{{success}} moved, {{errors}} failed",
"itemsMoved": "Items moved",
"itemsMovedBody": "{{count}} items moved successfully",
"batchDelete": "Batch delete",
"batchDeleteBody": "{{success}} moved to trash, {{errors}} failed",
"movedToTrash": "Moved to trash",
"movedToTrashBody": "{{count}} items moved to trash",
"trashItemsError": "Could not move items to trash",
"preparingDownload": "Preparing download",
"preparingDownloadBody": "Preparing your download…",
"downloadItemsError": "Could not download selected items",
"favoritesAddError": "Could not add items to favorites",
"invalidEmail": "Please enter a valid email address",
"notificationSendError": "Could not send notification",
"folderCreated": "Folder created",
"folderCreatedBody": "\"{{name}}\" created successfully",
"fileMoved": "File moved",
"fileMovedBody": "File moved successfully",
"fileMoveError": "Error moving the file: {{error}}",
"fileMoveErrorGeneric": "Error moving the file",
"folderMoved": "Folder moved",
"folderMovedBody": "Folder moved successfully",
"folderMoveError": "Error moving the folder: {{error}}",
"folderMoveErrorGeneric": "Error moving the folder",
"fileCopied": "File copied",
"fileCopiedBody": "File copied successfully",
"fileCopyError": "Error copying the file: {{error}}",
"fileCopyErrorGeneric": "Error copying the file",
"folderRenamed": "Folder renamed",
"folderRenamedBody": "Folder renamed to \"{{name}}\"",
"fileTrashed": "File moved to trash",
"fileTrashedBody": "\"{{name}}\" moved to trash",
"fileDeleted": "File deleted",
"fileDeletedBody": "\"{{name}}\" deleted successfully",
"fileDeleteError": "Error deleting the file",
"folderTrashed": "Folder moved to trash",
"folderTrashedBody": "\"{{name}}\" moved to trash",
"folderDeleted": "Folder deleted",
"folderDeletedBody": "\"{{name}}\" deleted successfully",
"folderDeleteError": "Error deleting the folder",
"itemRestored": "Item restored",
"itemRestoredBody": "Item restored successfully",
"itemRestoreError": "Error restoring the item",
"itemDeleted": "Item deleted",
"itemDeletedBody": "Item permanently deleted",
"itemDeleteError": "Error deleting the item",
"trashEmptied": "Trash emptied",
"trashEmptiedBody": "The trash has been emptied successfully",
"trashEmptyError": "Error emptying the trash",
"cacheCleared": "Cache cleared",
"cacheClearedBody": "Search cache cleared successfully",
"cacheClearError": "Error clearing search cache",
"wopiOpenError": "Could not open the document editor.",
"linkCopied": "Link copied",
"linkCopiedBody": "Link copied to clipboard",
"linkCopyError": "Could not copy link",
"notificationSent": "Notification sent",
"notificationSentBody": "Notification sent to {{email}}"
}
}

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