2026-06-17 17:06:30 -06:00
|
|
|
|
<script lang="ts">
|
|
|
|
|
|
import type { Snippet } from 'svelte';
|
|
|
|
|
|
import { goto } from '$app/navigation';
|
2026-06-21 19:03:07 +02:00
|
|
|
|
import { resolve } from '$app/paths';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import { page } from '$app/state';
|
|
|
|
|
|
import { logout } from '$lib/api/endpoints/auth';
|
2026-07-26 16:38:49 +02:00
|
|
|
|
import { searchResources } from '$lib/api/endpoints/search';
|
2026-07-22 23:10:47 +02:00
|
|
|
|
import { fileInlineUrl, deleteFile } from '$lib/api/endpoints/files';
|
|
|
|
|
|
import { deleteFolder } from '$lib/api/endpoints/folders';
|
|
|
|
|
|
import { addFavorite } from '$lib/api/endpoints/favorites';
|
|
|
|
|
|
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
2026-06-19 22:56:00 +00:00
|
|
|
|
import { lazyComponent } from '$lib/composables/lazyComponent.svelte';
|
2026-06-20 01:28:52 +02:00
|
|
|
|
import DrivePicker from '$lib/components/DrivePicker.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import Icon from '$lib/icons/Icon.svelte';
|
2026-07-17 11:10:27 +00:00
|
|
|
|
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
2026-06-20 00:23:54 +02:00
|
|
|
|
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
|
|
|
|
|
import { apiFetch } from '$lib/api/client';
|
2026-07-22 23:10:47 +02:00
|
|
|
|
import { dialogs } from '$lib/stores/dialogs.svelte';
|
2026-07-26 16:38:49 +02:00
|
|
|
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
2026-07-13 19:26:19 +02:00
|
|
|
|
import { preferences } from '$lib/stores/preferences.svelte';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import { session } from '$lib/stores/session.svelte';
|
|
|
|
|
|
import { theme, type Theme } from '$lib/stores/theme.svelte';
|
2026-06-17 22:07:18 -06:00
|
|
|
|
import { ui } from '$lib/stores/ui.svelte';
|
2026-07-22 23:10:47 +02:00
|
|
|
|
import { errorToast } from '$lib/utils/errors';
|
2026-06-17 17:06:30 -06:00
|
|
|
|
import { formatBytes } from '$lib/utils/format';
|
|
|
|
|
|
|
|
|
|
|
|
let { children }: { children: Snippet } = $props();
|
|
|
|
|
|
|
2026-06-19 22:56:00 +00:00
|
|
|
|
// The command palette is loaded on its first Cmd/Ctrl+K and mounted open.
|
|
|
|
|
|
// Until then its ~400-line module stays out of the initial bundle.
|
|
|
|
|
|
const palette = lazyComponent(() => import('$lib/components/CommandPalette.svelte'));
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
interface NavLink {
|
2026-07-30 00:52:12 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* String rather than a literal union so admin links (which
|
|
|
|
|
|
* include a dynamic path segment) can share the same shape.
|
|
|
|
|
|
* `resolve()` accepts any string, so no type-level cost.
|
|
|
|
|
|
*/
|
|
|
|
|
|
href: string;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
label: string;
|
|
|
|
|
|
icon: string;
|
2026-06-19 22:49:59 +00:00
|
|
|
|
/** Stable key driving the per-section icon colour (see sidebar.css). */
|
|
|
|
|
|
section: string;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
admin?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const LINKS: NavLink[] = [
|
2026-06-19 22:49:59 +00:00
|
|
|
|
{ href: '/files', label: t('nav.files', 'Files'), icon: 'folder', section: 'files' },
|
|
|
|
|
|
{ href: '/shared', label: t('nav.shared', 'Shared'), icon: 'oxiexport', section: 'shared' },
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{
|
|
|
|
|
|
href: '/shared-with-me',
|
|
|
|
|
|
label: t('nav.shared_with_me', 'Shared with me'),
|
2026-06-19 22:49:59 +00:00
|
|
|
|
icon: 'oxiimport',
|
|
|
|
|
|
section: 'shared-with-me'
|
2026-06-17 17:06:30 -06:00
|
|
|
|
},
|
2026-06-19 22:49:59 +00:00
|
|
|
|
{ href: '/recent', label: t('nav.recent', 'Recent'), icon: 'clock', section: 'recent' },
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/favorites',
|
|
|
|
|
|
label: t('nav.favorites', 'Favorites'),
|
|
|
|
|
|
icon: 'star',
|
|
|
|
|
|
section: 'favorites'
|
|
|
|
|
|
},
|
|
|
|
|
|
{ href: '/photos', label: t('nav.photos', 'Photos'), icon: 'images', section: 'photos' },
|
|
|
|
|
|
{ href: '/music', label: t('nav.music', 'Music'), icon: 'music', section: 'music' },
|
|
|
|
|
|
{ href: '/trash', label: t('nav.trash', 'Trash'), icon: 'trash', section: 'trash' }
|
2026-06-17 17:06:30 -06:00
|
|
|
|
];
|
|
|
|
|
|
|
2026-07-30 00:52:12 +02:00
|
|
|
|
// Admin sidebar — populated when the URL is under /admin. The
|
|
|
|
|
|
// admin +page.svelte used to render its own horizontal tab
|
|
|
|
|
|
// strip; that was displaced here so the section navigation
|
|
|
|
|
|
// scales past ~7 items and matches deep-link URLs from the
|
|
|
|
|
|
// address bar.
|
|
|
|
|
|
const ADMIN_LINKS: NavLink[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin',
|
|
|
|
|
|
label: t('admin.dashboard', 'Dashboard'),
|
|
|
|
|
|
icon: 'chart-pie',
|
|
|
|
|
|
section: 'admin-dashboard'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/users',
|
|
|
|
|
|
label: t('admin.users', 'Users'),
|
|
|
|
|
|
icon: 'users',
|
|
|
|
|
|
section: 'admin-users'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/drives',
|
|
|
|
|
|
label: t('admin.drives', 'Drives'),
|
|
|
|
|
|
icon: 'folder',
|
|
|
|
|
|
section: 'admin-drives'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/mounts',
|
|
|
|
|
|
label: t('admin.mounts', 'External Mounts'),
|
|
|
|
|
|
icon: 'folder',
|
|
|
|
|
|
section: 'admin-mounts'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/oidc',
|
|
|
|
|
|
label: t('admin.oidc', 'OIDC / SSO'),
|
|
|
|
|
|
icon: 'key',
|
|
|
|
|
|
section: 'admin-oidc'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/storage',
|
|
|
|
|
|
label: t('admin.storage_tab', 'Storage'),
|
|
|
|
|
|
icon: 'database',
|
|
|
|
|
|
section: 'admin-storage'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/smtp',
|
|
|
|
|
|
label: t('admin.smtp', 'Email (SMTP)'),
|
|
|
|
|
|
icon: 'envelope',
|
|
|
|
|
|
section: 'admin-smtp'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/plugins',
|
|
|
|
|
|
label: t('admin.plugins', 'Plugins'),
|
|
|
|
|
|
icon: 'layer-group',
|
|
|
|
|
|
section: 'admin-plugins'
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
href: '/admin/jobs',
|
|
|
|
|
|
label: t('admin.jobs.tab', 'Jobs'),
|
|
|
|
|
|
icon: 'cogs',
|
|
|
|
|
|
section: 'admin-jobs'
|
|
|
|
|
|
}
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
const isAdmin = $derived(session.user?.role === 'admin');
|
|
|
|
|
|
|
2026-07-30 00:52:12 +02:00
|
|
|
|
// Any URL under /admin swaps the sidebar to admin mode. Uses
|
|
|
|
|
|
// startsWith so a trailing slash / query params / hash don't
|
|
|
|
|
|
// desync. Root `/admin` counts too (dashboard).
|
|
|
|
|
|
const isAdminSection = $derived(page.url.pathname.startsWith('/admin'));
|
|
|
|
|
|
const currentLinks = $derived(isAdminSection ? ADMIN_LINKS : LINKS);
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
function active(href: string): boolean {
|
|
|
|
|
|
return page.url.pathname === href || page.url.pathname.startsWith(`${href}/`);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 00:52:12 +02:00
|
|
|
|
// Sidebar-item active check. Non-admin links use `active()`
|
|
|
|
|
|
// (matches href + any subpath). Admin links need a stricter
|
|
|
|
|
|
// rule for `/admin` itself — a plain `startsWith('/admin/')`
|
|
|
|
|
|
// would light up the Dashboard item on `/admin/drives` too.
|
|
|
|
|
|
// So `/admin` matches ONLY the exact path; every other admin
|
|
|
|
|
|
// item uses the same startsWith rule as before.
|
|
|
|
|
|
function activeLink(href: string): boolean {
|
|
|
|
|
|
if (href === '/admin') return page.url.pathname === '/admin';
|
|
|
|
|
|
return active(href);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Data-driven sidebar links (`LINKS`, `ADMIN_LINKS`) hold
|
|
|
|
|
|
* runtime strings, not compile-time route keys. SvelteKit's
|
|
|
|
|
|
* typed `resolve()` refuses them; we know they're valid
|
|
|
|
|
|
* routes at runtime. Cast at the callsite, one place, so
|
|
|
|
|
|
* the template stays clean.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function navHref(href: string): string {
|
|
|
|
|
|
// @ts-expect-error runtime-known route string, not a literal typed key
|
|
|
|
|
|
return resolve(href);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-22 23:10:47 +02:00
|
|
|
|
// ── Sidebar drop targets ─────────────────────────────────────────────────
|
|
|
|
|
|
// The row-drag on `/files` (and other resource surfaces) sets a
|
|
|
|
|
|
// `application/x-oxi-item` MIME with a JSON array of `{ id, name, kind }`.
|
|
|
|
|
|
// The Favorites and Trash sidebar links accept a drop of that shape:
|
|
|
|
|
|
// – Favorites: batch-add each item as a favorite (idempotent server-side).
|
|
|
|
|
|
// – Trash: yes/no confirm dialog, then batch-delete on approval.
|
|
|
|
|
|
// Every other sidebar link stays inert (no dragover ⇒ no drop cursor).
|
|
|
|
|
|
const SIDEBAR_DRAG_TYPE = 'application/x-oxi-item';
|
|
|
|
|
|
interface DragItem {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
name: string;
|
|
|
|
|
|
kind: ItemType;
|
|
|
|
|
|
}
|
|
|
|
|
|
let sidebarDropHref = $state<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
function sidebarOnDragOver(e: DragEvent, href: string) {
|
|
|
|
|
|
if (!e.dataTransfer?.types.includes(SIDEBAR_DRAG_TYPE)) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
// Favorites is a pure add — signal "copy". Trash is destructive — signal
|
|
|
|
|
|
// "move" so the cursor doesn't imply a benign copy for something that
|
|
|
|
|
|
// actually removes the row from its source view.
|
|
|
|
|
|
e.dataTransfer.dropEffect = href === '/favorites' ? 'copy' : 'move';
|
|
|
|
|
|
sidebarDropHref = href;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sidebarOnDragLeave(href: string) {
|
|
|
|
|
|
if (sidebarDropHref === href) sidebarDropHref = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseDragItems(e: DragEvent): DragItem[] {
|
|
|
|
|
|
const raw = e.dataTransfer?.getData(SIDEBAR_DRAG_TYPE);
|
|
|
|
|
|
if (!raw) return [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
|
|
|
|
if (!Array.isArray(parsed)) return [];
|
|
|
|
|
|
return parsed.filter(
|
|
|
|
|
|
(it): it is DragItem =>
|
|
|
|
|
|
!!it &&
|
|
|
|
|
|
typeof it === 'object' &&
|
|
|
|
|
|
typeof (it as DragItem).id === 'string' &&
|
|
|
|
|
|
typeof (it as DragItem).name === 'string' &&
|
|
|
|
|
|
((it as DragItem).kind === 'file' || (it as DragItem).kind === 'folder')
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Small bounded fan-out — same shape as `/files`'s `mapLimit` but
|
|
|
|
|
|
// inlined so AppShell doesn't grow a shared-util dependency for two
|
|
|
|
|
|
// call sites. A drop is usually small (single row or a page of
|
|
|
|
|
|
// selection), so 6 concurrent requests is plenty.
|
|
|
|
|
|
async function fanout<T>(items: T[], limit: number, fn: (item: T) => Promise<void>) {
|
|
|
|
|
|
let next = 0;
|
|
|
|
|
|
const worker = async () => {
|
|
|
|
|
|
while (next < items.length) {
|
|
|
|
|
|
const i = next++;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await fn(items[i]);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
errorToast(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sidebarOnDrop(e: DragEvent, href: string) {
|
|
|
|
|
|
// Narrow the sidebar-link href to the two accepting targets. Called
|
|
|
|
|
|
// from the row template, which can't type-narrow the loop variable
|
|
|
|
|
|
// through the arrow-function closure without a local capture.
|
|
|
|
|
|
if (href !== '/favorites' && href !== '/trash') return;
|
|
|
|
|
|
sidebarDropHref = null;
|
|
|
|
|
|
if (!e.dataTransfer?.types.includes(SIDEBAR_DRAG_TYPE)) return;
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const items = parseDragItems(e);
|
|
|
|
|
|
if (items.length === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (href === '/favorites') {
|
|
|
|
|
|
await fanout(items, 6, async (it) => {
|
|
|
|
|
|
await addFavorite(it.kind, it.id);
|
|
|
|
|
|
// Broadcast so a mounted resource-list source page (e.g. /files,
|
|
|
|
|
|
// /recent, /shared-with-me) can flip the row's `is_favorite` in
|
|
|
|
|
|
// place without a re-fetch. ResourceList listens for the same
|
|
|
|
|
|
// event and patches its `items` prop. Uses the existing
|
|
|
|
|
|
// window-event pattern (see `oxicloud:upload-files`).
|
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
|
new CustomEvent('oxicloud:favorite-changed', {
|
|
|
|
|
|
detail: { id: it.id, is_favorite: true }
|
|
|
|
|
|
})
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
ui.notify(
|
|
|
|
|
|
t('favorites.added_n', { n: items.length }, 'Added {{n}} item(s) to favorites'),
|
|
|
|
|
|
'success'
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Trash: destructive → gate on confirm.
|
|
|
|
|
|
const ok = await dialogs.confirm({
|
|
|
|
|
|
title: t('files.batch_delete', 'Delete selected'),
|
|
|
|
|
|
message:
|
|
|
|
|
|
items.length === 1
|
|
|
|
|
|
? t('files.confirm_delete', { name: items[0].name }, 'Move "{{name}}" to trash?')
|
|
|
|
|
|
: t('files.confirm_batch_delete', { n: items.length }, 'Move {{n}} items to trash?'),
|
|
|
|
|
|
confirmText: t('common.delete', 'Delete'),
|
|
|
|
|
|
danger: true
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
|
await fanout(items, 6, (it) => (it.kind === 'file' ? deleteFile(it.id) : deleteFolder(it.id)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
let sidebarOpen = $state(false);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
let notifOpen = $state(false);
|
|
|
|
|
|
let menuOpen = $state(false);
|
|
|
|
|
|
let searchQuery = $state('');
|
|
|
|
|
|
/** Mobile collapsible-search overlay state (toggles .top-bar--search-active). */
|
|
|
|
|
|
let searchActive = $state(false);
|
|
|
|
|
|
let langOpen = $state(false);
|
|
|
|
|
|
let aboutOpen = $state(false);
|
|
|
|
|
|
let appVersion = $state('');
|
|
|
|
|
|
let searchInputEl = $state<HTMLInputElement | null>(null);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
// Bell ring/auto-open: react to the store's bellPing token (bumped on upload
|
|
|
|
|
|
// start etc.). Open the panel and replay the ring animation.
|
|
|
|
|
|
let bellRinging = $state(false);
|
|
|
|
|
|
let lastPing = 0;
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
|
const p = ui.bellPing;
|
|
|
|
|
|
if (p === lastPing) return;
|
|
|
|
|
|
lastPing = p;
|
|
|
|
|
|
if (p === 0) return;
|
|
|
|
|
|
notifOpen = true;
|
|
|
|
|
|
menuOpen = false;
|
|
|
|
|
|
bellRinging = false;
|
|
|
|
|
|
// Force a reflow gap before re-adding so the CSS animation restarts.
|
|
|
|
|
|
requestAnimationFrame(() => (bellRinging = true));
|
|
|
|
|
|
setTimeout(() => (bellRinging = false), 900);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function openMobileSearch() {
|
|
|
|
|
|
searchActive = true;
|
|
|
|
|
|
requestAnimationFrame(() => searchInputEl?.focus());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMobileSearch() {
|
|
|
|
|
|
searchActive = false;
|
|
|
|
|
|
clearSearch();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function openAbout() {
|
|
|
|
|
|
menuOpen = false;
|
|
|
|
|
|
aboutOpen = true;
|
|
|
|
|
|
if (!appVersion) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await apiFetch('/api/version', { credentials: 'same-origin' });
|
|
|
|
|
|
if (r.ok) {
|
|
|
|
|
|
const data = (await r.json()) as { version?: string };
|
|
|
|
|
|
if (data.version) appVersion = `v${data.version}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* version stays blank — non-critical */
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Top-bar autocomplete
|
|
|
|
|
|
type Suggestion = { kind: 'folder'; item: FolderItem } | { kind: 'file'; item: FileItem };
|
|
|
|
|
|
let suggestions = $state<Suggestion[]>([]);
|
|
|
|
|
|
let suggestOpen = $state(false);
|
|
|
|
|
|
let suggestBusy = $state(false);
|
|
|
|
|
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-07-18 20:33:50 +00:00
|
|
|
|
// Stale-response guard (same family as the search page): the debounce
|
|
|
|
|
|
// spaces requests out but doesn't stop a SLOW earlier response from
|
|
|
|
|
|
// resolving after a newer one and overwriting its suggestions.
|
|
|
|
|
|
let suggestSeq = 0;
|
|
|
|
|
|
let suggestInflight: AbortController | null = null;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
|
|
function goToResults() {
|
|
|
|
|
|
const q = searchQuery.trim();
|
2026-07-26 16:38:49 +02:00
|
|
|
|
if (!q) return;
|
|
|
|
|
|
suggestOpen = false;
|
|
|
|
|
|
searchActive = false;
|
|
|
|
|
|
// Carry the currently-open folder into the search URL as `?in=<uuid>`
|
|
|
|
|
|
// so a hard refresh, a shared link, or a bookmark all restore the
|
|
|
|
|
|
// "This folder" scope. Trash section is always global — skip. See
|
|
|
|
|
|
// `/search/+page.svelte` for the receiver side.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Built by hand instead of via `URLSearchParams` because the Svelte
|
|
|
|
|
|
// lint (svelte/prefer-svelte-reactivity) flags the mutable stdlib
|
|
|
|
|
|
// variant; the two params here don't need reactivity anyway.
|
|
|
|
|
|
const parts = [`q=${encodeURIComponent(q)}`];
|
|
|
|
|
|
if (filesStore.currentFolder && filesStore.section !== 'trash') {
|
|
|
|
|
|
parts.push(`in=${encodeURIComponent(filesStore.currentFolder)}`);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
2026-07-26 16:38:49 +02:00
|
|
|
|
goto(resolve(`/search?${parts.join('&')}`));
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onSearch(e: SubmitEvent) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
goToResults();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onSearchInput() {
|
|
|
|
|
|
if (suggestTimer) clearTimeout(suggestTimer);
|
|
|
|
|
|
const q = searchQuery.trim();
|
|
|
|
|
|
if (q.length < 2) {
|
2026-07-18 20:33:50 +00:00
|
|
|
|
suggestSeq++;
|
|
|
|
|
|
suggestInflight?.abort();
|
|
|
|
|
|
suggestInflight = null;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
suggestions = [];
|
|
|
|
|
|
suggestOpen = false;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
suggestTimer = setTimeout(async () => {
|
2026-07-18 20:33:50 +00:00
|
|
|
|
const seq = ++suggestSeq;
|
|
|
|
|
|
suggestInflight?.abort();
|
|
|
|
|
|
const ctl = new AbortController();
|
|
|
|
|
|
suggestInflight = ctl;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
suggestBusy = true;
|
|
|
|
|
|
try {
|
2026-07-26 16:38:49 +02:00
|
|
|
|
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal });
|
2026-07-18 20:33:50 +00:00
|
|
|
|
if (seq !== suggestSeq) return; // superseded while awaiting
|
2026-07-26 16:38:49 +02:00
|
|
|
|
// The wire is ordered — folders first, then files — but slice
|
|
|
|
|
|
// per kind explicitly so the header preview stays a folder-heavy
|
|
|
|
|
|
// list even when files dominate the result set.
|
|
|
|
|
|
const folders = r.items
|
|
|
|
|
|
.filter((it) => it.resource_type === 'folder')
|
|
|
|
|
|
.slice(0, 3)
|
|
|
|
|
|
.map((it) => ({ kind: 'folder' as const, item: it.resource as FolderItem }));
|
|
|
|
|
|
const files = r.items
|
|
|
|
|
|
.filter((it) => it.resource_type === 'file')
|
|
|
|
|
|
.slice(0, 6)
|
|
|
|
|
|
.map((it) => ({ kind: 'file' as const, item: it.resource as FileItem }));
|
|
|
|
|
|
suggestions = [...folders, ...files];
|
2026-06-17 22:07:18 -06:00
|
|
|
|
suggestOpen = suggestions.length > 0;
|
|
|
|
|
|
} catch {
|
2026-07-18 20:33:50 +00:00
|
|
|
|
if (seq !== suggestSeq || ctl.signal.aborted) return;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
suggestions = [];
|
|
|
|
|
|
suggestOpen = false;
|
|
|
|
|
|
} finally {
|
2026-07-18 20:33:50 +00:00
|
|
|
|
if (seq === suggestSeq) suggestBusy = false;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
}, 250);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearSearch() {
|
|
|
|
|
|
searchQuery = '';
|
|
|
|
|
|
suggestions = [];
|
|
|
|
|
|
suggestOpen = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pickSuggestion(s: Suggestion) {
|
|
|
|
|
|
suggestOpen = false;
|
2026-06-21 19:03:07 +02:00
|
|
|
|
if (s.kind === 'folder') goto(resolve(`/files/${s.item.id}`));
|
2026-06-17 22:07:18 -06:00
|
|
|
|
else window.open(fileInlineUrl(s.item.id), '_blank', 'noopener');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const THEMES: { mode: Theme; icon: string; label: string }[] = [
|
|
|
|
|
|
{ mode: 'light', icon: 'sun', label: t('user_menu.theme.light', 'Light') },
|
|
|
|
|
|
{ mode: 'auto', icon: 'desktop', label: t('user_menu.theme.auto', 'Like OS') },
|
|
|
|
|
|
{ mode: 'dark', icon: 'moon', label: t('user_menu.theme.dark', 'Dark') }
|
|
|
|
|
|
];
|
2026-06-17 17:06:30 -06:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-20 00:23:54 +02:00
|
|
|
|
const initials = $derived(userInitials(session.user?.username || session.user?.email));
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
|
|
/** Uploaded avatar photo URL, if any. */
|
|
|
|
|
|
const avatarPhoto = $derived(session.user?.image ?? null);
|
|
|
|
|
|
|
2026-06-20 00:23:54 +02:00
|
|
|
|
/** Deterministic colour bucket 0–4 from the user id (shared with UserVignette). */
|
|
|
|
|
|
const avatarColor = $derived(avatarColorIndex(session.user?.id));
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
|
|
|
|
|
function closeMenus() {
|
|
|
|
|
|
notifOpen = false;
|
|
|
|
|
|
menuOpen = false;
|
|
|
|
|
|
langOpen = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-13 19:26:19 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* True when the shortcut target is a text-input surface — <input>,
|
|
|
|
|
|
* <textarea>, or any `contenteditable` element. Used by the
|
|
|
|
|
|
* Cmd/Ctrl+Shift+. shortcut to defer to normal typing when the
|
|
|
|
|
|
* user is composing text (otherwise typing `.` while holding Shift
|
|
|
|
|
|
* in a filename dialog would fight the shortcut).
|
|
|
|
|
|
*/
|
|
|
|
|
|
function isTextFieldFocused(target: EventTarget | null): boolean {
|
|
|
|
|
|
if (!(target instanceof HTMLElement)) return false;
|
|
|
|
|
|
const tag = target.tagName;
|
|
|
|
|
|
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
async function chooseLocale(loc: Locale) {
|
|
|
|
|
|
langOpen = false;
|
|
|
|
|
|
await setLocale(loc);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const currentLang = $derived(LANGUAGES.find((l) => l.code === i18n.locale) ?? LANGUAGES[0]);
|
|
|
|
|
|
|
|
|
|
|
|
function formatTime(ms: number): string {
|
2026-07-17 11:10:27 +00:00
|
|
|
|
return dateTimeFormatFor(undefined, { hour: '2-digit', minute: '2-digit' }).format(ms);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function notifIcon(kind: string): string {
|
|
|
|
|
|
switch (kind) {
|
|
|
|
|
|
case 'success':
|
|
|
|
|
|
return 'check';
|
|
|
|
|
|
case 'error':
|
|
|
|
|
|
return 'exclamation-circle';
|
|
|
|
|
|
case 'warning':
|
|
|
|
|
|
return 'exclamation-triangle';
|
|
|
|
|
|
default:
|
|
|
|
|
|
return 'info-circle';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
async function onLogout() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await logout();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* clear locally regardless */
|
|
|
|
|
|
}
|
|
|
|
|
|
session.reset();
|
2026-06-21 19:03:07 +02:00
|
|
|
|
await goto(resolve('/login'));
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<svelte:window
|
|
|
|
|
|
onclick={closeMenus}
|
|
|
|
|
|
onkeydown={(e) => {
|
2026-06-19 22:56:00 +00:00
|
|
|
|
// First Cmd/Ctrl+K loads the palette and mounts it open; once mounted,
|
|
|
|
|
|
// the palette's own handler takes over toggling/closing.
|
|
|
|
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k' && !palette.component) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
void palette.load();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-13 19:26:19 +02:00
|
|
|
|
// Cmd/Ctrl+Shift+. toggles dotfile visibility — matches macOS
|
|
|
|
|
|
// Finder's convention. `e.code === 'Period'` targets the
|
|
|
|
|
|
// physical key regardless of keyboard layout (Cmd+Shift+.
|
|
|
|
|
|
// yields `.key === '>'` on some layouts). Skip when focus is
|
|
|
|
|
|
// inside a text field so users can still type `.` in inputs.
|
|
|
|
|
|
if (
|
|
|
|
|
|
(e.metaKey || e.ctrlKey) &&
|
|
|
|
|
|
e.shiftKey &&
|
|
|
|
|
|
e.code === 'Period' &&
|
|
|
|
|
|
!isTextFieldFocused(e.target)
|
|
|
|
|
|
) {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
preferences.toggleHideDotfiles();
|
|
|
|
|
|
ui.notify(
|
|
|
|
|
|
preferences.hideDotfiles
|
|
|
|
|
|
? t('files.dotfiles_hidden_toast', 'Dotfiles hidden')
|
|
|
|
|
|
: t('files.dotfiles_shown_toast', 'Dotfiles shown'),
|
|
|
|
|
|
'info',
|
|
|
|
|
|
2000,
|
|
|
|
|
|
false
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
if (e.key !== 'Escape') return;
|
|
|
|
|
|
if (aboutOpen) aboutOpen = false;
|
|
|
|
|
|
else if (searchActive) closeMobileSearch();
|
|
|
|
|
|
else closeMenus();
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<div
|
|
|
|
|
|
class="sidebar-overlay"
|
|
|
|
|
|
class:active={sidebarOpen}
|
|
|
|
|
|
onclick={() => (sidebarOpen = false)}
|
|
|
|
|
|
role="presentation"
|
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="sidebar" class:open={sidebarOpen}>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a href={resolve('/files')} class="logo-container" data-testid="appshell-logo-link">
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<div class="logo">
|
2026-06-20 14:42:10 +02:00
|
|
|
|
<svg viewBox="95 67 320 320" aria-hidden="true">
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-07-30 00:52:12 +02:00
|
|
|
|
<nav
|
|
|
|
|
|
class="nav-menu"
|
|
|
|
|
|
class:nav-menu--admin={isAdminSection}
|
|
|
|
|
|
aria-label={isAdminSection
|
|
|
|
|
|
? t('nav.admin_sections', 'Admin sections')
|
|
|
|
|
|
: t('nav.primary', 'Primary')}
|
|
|
|
|
|
>
|
|
|
|
|
|
{#if isAdminSection}
|
|
|
|
|
|
<!--
|
|
|
|
|
|
"Back to app" escape hatch — the sidebar swaps to
|
|
|
|
|
|
admin mode when the URL is under /admin/*, so the
|
|
|
|
|
|
usual Files/Shared links disappear. A dedicated back
|
|
|
|
|
|
entry gives the operator a one-click way out without
|
|
|
|
|
|
having to hunt through the user menu.
|
|
|
|
|
|
-->
|
|
|
|
|
|
<a
|
|
|
|
|
|
class="nav-item nav-item--back"
|
|
|
|
|
|
href={resolve('/files')}
|
|
|
|
|
|
data-testid="appshell-nav-admin-back-link"
|
|
|
|
|
|
onclick={() => (sidebarOpen = false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="arrow-left" />
|
|
|
|
|
|
<span>{t('nav.back_to_app', 'Back to OxiCloud')}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
<!--
|
|
|
|
|
|
Sidebar links come from data-driven arrays (LINKS,
|
|
|
|
|
|
ADMIN_LINKS), so hrefs are runtime strings rather than
|
|
|
|
|
|
compile-time-known route keys. `navHref()` internally
|
|
|
|
|
|
routes them through `resolve()`, but ESLint's static
|
|
|
|
|
|
check can't see through the wrapper. Disabling the
|
|
|
|
|
|
rule here rather than sprinkling per-line directives.
|
|
|
|
|
|
-->
|
|
|
|
|
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
|
|
|
|
|
{#each currentLinks as link (link.href)}
|
|
|
|
|
|
{@const isDropTarget =
|
|
|
|
|
|
!isAdminSection && (link.href === '/favorites' || link.href === '/trash')}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="nav-item"
|
2026-07-30 00:52:12 +02:00
|
|
|
|
class:active={activeLink(link.href)}
|
2026-07-22 23:10:47 +02:00
|
|
|
|
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
|
2026-07-30 00:52:12 +02:00
|
|
|
|
href={navHref(link.href)}
|
2026-06-19 22:49:59 +00:00
|
|
|
|
data-section={link.section}
|
2026-07-30 00:52:12 +02:00
|
|
|
|
data-testid={`appshell-nav-${link.section}-link`}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
onclick={() => (sidebarOpen = false)}
|
2026-07-22 23:10:47 +02:00
|
|
|
|
ondragover={isDropTarget ? (e) => sidebarOnDragOver(e, link.href) : undefined}
|
|
|
|
|
|
ondragleave={isDropTarget ? () => sidebarOnDragLeave(link.href) : undefined}
|
|
|
|
|
|
ondrop={isDropTarget ? (e) => void sidebarOnDrop(e, link.href) : undefined}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name={link.icon} />
|
|
|
|
|
|
<span>{link.label}</span>
|
|
|
|
|
|
</a>
|
2026-07-30 00:52:12 +02:00
|
|
|
|
{#if !isAdminSection && link.href === '/files' && !session.isExternalUser}
|
2026-06-20 01:28:52 +02:00
|
|
|
|
<DrivePicker onnavigate={() => (sidebarOpen = false)} />
|
|
|
|
|
|
{/if}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{/each}
|
2026-07-30 00:52:12 +02:00
|
|
|
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</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}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{Math.round(storagePct)}% · {formatBytes(session.user.storage_used_bytes)} / {formatBytes(
|
2026-06-17 17:06:30 -06:00
|
|
|
|
session.user.storage_quota_bytes
|
|
|
|
|
|
)}
|
|
|
|
|
|
{:else}
|
2026-06-20 14:42:10 +02:00
|
|
|
|
{formatBytes(session.user.storage_used_bytes)}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="main-content">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<div class="top-bar" class:top-bar--search-active={searchActive}>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="sidebar-toggle"
|
2026-06-17 22:52:06 -06:00
|
|
|
|
aria-label={t('nav.toggle', 'Toggle navigation menu')}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
aria-expanded={sidebarOpen}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-sidebar-toggle-btn"
|
2026-06-17 17:06:30 -06:00
|
|
|
|
onclick={() => (sidebarOpen = !sidebarOpen)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="bars" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<!-- Mobile: icon-only button that expands the full-width search overlay. -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="search-toggle-btn"
|
|
|
|
|
|
id="search-toggle-btn"
|
|
|
|
|
|
aria-label={t('actions.search_btn', 'Search')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-search-toggle-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={openMobileSearch}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="search" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Mobile: back arrow shown while the search overlay is expanded. -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="search-back-btn"
|
|
|
|
|
|
aria-label={t('common.close', 'Close')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-search-back-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={closeMobileSearch}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="arrow-left" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="search-slot">
|
|
|
|
|
|
<form class="search-container" onsubmit={onSearch}>
|
|
|
|
|
|
<Icon name="search" class="search-icon" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
bind:this={searchInputEl}
|
|
|
|
|
|
bind:value={searchQuery}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-search-input"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
oninput={onSearchInput}
|
|
|
|
|
|
onfocus={() => (suggestOpen = suggestions.length > 0)}
|
|
|
|
|
|
onblur={() => setTimeout(() => (suggestOpen = false), 150)}
|
|
|
|
|
|
placeholder={t('actions.search', 'Search files, folders...')}
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{#if searchQuery}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<button
|
2026-06-17 22:07:18 -06:00
|
|
|
|
class="search-clear"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
title={t('common.clear', 'Clear')}
|
|
|
|
|
|
aria-label={t('common.clear', 'Clear')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-search-clear-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={clearSearch}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="times" />
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</button>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{/if}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="search-button"
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
title={t('actions.search_btn', 'Search')}
|
2026-06-17 22:52:06 -06:00
|
|
|
|
aria-label={t('actions.search_btn', 'Search')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-search-submit-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name="search" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
{#if suggestOpen}
|
|
|
|
|
|
<ul class="suggest">
|
|
|
|
|
|
{#each suggestions as s (s.kind + s.item.id)}
|
|
|
|
|
|
<li>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="suggest__item"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
data-testid={`appshell-search-suggestion-${s.kind}-${s.item.id}-item`}
|
|
|
|
|
|
onmousedown={() => pickSuggestion(s)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<span class="suggest__icon">
|
|
|
|
|
|
{#if s.kind === 'folder'}
|
|
|
|
|
|
<Icon name="folder" />
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<Icon name={iconNameFromClass(s.item.icon_class)} />
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="suggest__name">{s.item.name}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
<li>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="suggest__all"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
data-testid="appshell-search-see-all-btn"
|
|
|
|
|
|
onmousedown={goToResults}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{t('search.see_all', 'See all results')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{:else if suggestBusy}
|
|
|
|
|
|
<ul class="suggest"><li class="suggest__busy">{t('common.loading', 'Loading…')}</li></ul>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-controls">
|
|
|
|
|
|
<!-- Notifications -->
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<div class="notif-wrapper" class:open={notifOpen} data-testid="appshell-notif-menu">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="notif-bell-btn"
|
|
|
|
|
|
class:active={notifOpen}
|
|
|
|
|
|
class:ring={bellRinging}
|
|
|
|
|
|
aria-label={t('notifications.title', 'Notifications')}
|
|
|
|
|
|
aria-haspopup="true"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-notif-bell-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
notifOpen = !notifOpen;
|
|
|
|
|
|
menuOpen = false;
|
|
|
|
|
|
if (notifOpen) ui.markNotificationsRead();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="bell" />
|
|
|
|
|
|
{#if ui.unread > 0}<span class="notif-badge">{ui.unreadBadge}</span>{/if}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<div class="notif-panel">
|
|
|
|
|
|
<div class="notif-panel-header">
|
|
|
|
|
|
<span class="notif-panel-title">{t('notifications.title', 'Notifications')}</span>
|
|
|
|
|
|
{#if ui.notifications.length > 0}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="notif-clear-btn"
|
|
|
|
|
|
title={t('notifications.clear', 'Clear all')}
|
|
|
|
|
|
aria-label={t('notifications.clear', 'Clear all')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-notif-clear-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
ui.clearNotifications();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="trash-alt" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="notif-panel-body">
|
|
|
|
|
|
{#if ui.notifications.length === 0}
|
|
|
|
|
|
<div class="notif-empty">
|
|
|
|
|
|
<Icon name="bell-slash" />
|
|
|
|
|
|
<span>{t('notifications.empty', 'No notifications')}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
{#each ui.notifications as n (n.id)}
|
|
|
|
|
|
<div class="notif-item notif-item--{n.kind}">
|
|
|
|
|
|
<span class="notif-item-icon"><Icon name={n.icon ?? notifIcon(n.kind)} /></span>
|
|
|
|
|
|
<div class="notif-item-body">
|
|
|
|
|
|
<div class="notif-item-text">{n.message}</div>
|
|
|
|
|
|
{#if n.currentFile}
|
|
|
|
|
|
<div class="notif-item-current" title={n.currentFile}>{n.currentFile}</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{#if n.progress !== undefined}
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="notif-progress"
|
|
|
|
|
|
role="progressbar"
|
|
|
|
|
|
aria-valuenow={n.progress}
|
|
|
|
|
|
aria-valuemin="0"
|
|
|
|
|
|
aria-valuemax="100"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="notif-progress__fill" style:width="{n.progress}%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="notif-progress-detail">
|
|
|
|
|
|
<span>{n.progress}%</span>
|
|
|
|
|
|
{#if n.total !== undefined}
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'upload.files_counter',
|
|
|
|
|
|
{ done: n.completed ?? 0, total: n.total },
|
|
|
|
|
|
`${n.completed ?? 0} / ${n.total} files`
|
|
|
|
|
|
)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<div class="notif-item-time">{formatTime(n.at)}</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<!-- User menu -->
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<div class="user-menu-wrapper" class:open={menuOpen} data-testid="appshell-user-menu">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="user-avatar-btn"
|
|
|
|
|
|
aria-label={t('user_menu.title', 'User menu')}
|
|
|
|
|
|
aria-haspopup="true"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-user-menu-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
menuOpen = !menuOpen;
|
|
|
|
|
|
notifOpen = false;
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{@render avatar(false)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu">
|
|
|
|
|
|
{#if session.user}
|
|
|
|
|
|
<div class="user-menu-header">
|
|
|
|
|
|
{@render avatar(true)}
|
|
|
|
|
|
<div class="user-menu-id">
|
|
|
|
|
|
<div class="user-menu-name">{session.user.username || session.user.email}</div>
|
|
|
|
|
|
<div class="user-menu-email">{session.user.email}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if isAdmin}
|
|
|
|
|
|
<div class="user-menu-role-badge">
|
|
|
|
|
|
<span class="role-badge role-badge-admin">
|
|
|
|
|
|
<Icon name="shield-alt" />
|
|
|
|
|
|
{t('user_menu.admin', 'Admin')}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-storage">
|
|
|
|
|
|
<div class="user-menu-storage-label">
|
|
|
|
|
|
<Icon name="database" /> <span>{t('storage.title', 'Storage')}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="user-menu-storage-bar">
|
|
|
|
|
|
<div class="user-menu-storage-fill" style:width="{storagePct}%"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="user-menu-storage-text">
|
2026-06-20 14:42:10 +02:00
|
|
|
|
{#if session.user.storage_quota_bytes > 0}
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'storage.used',
|
|
|
|
|
|
{
|
|
|
|
|
|
percentage: Math.round(storagePct),
|
|
|
|
|
|
used: formatBytes(session.user.storage_used_bytes),
|
|
|
|
|
|
total: formatBytes(session.user.storage_quota_bytes)
|
|
|
|
|
|
},
|
|
|
|
|
|
'{{percentage}}% used ({{used}} / {{total}})'
|
|
|
|
|
|
)}
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
{formatBytes(session.user.storage_used_bytes)}
|
|
|
|
|
|
{/if}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-divider"></div>
|
|
|
|
|
|
|
|
|
|
|
|
{#if isAdmin}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="user-menu-item"
|
|
|
|
|
|
href={resolve('/admin')}
|
|
|
|
|
|
data-testid="appshell-user-menu-admin-item"
|
|
|
|
|
|
onclick={() => (menuOpen = false)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="cogs" /> <span>{t('user_menu.admin_panel', 'Admin panel')}</span>
|
|
|
|
|
|
</a>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="user-menu-item"
|
|
|
|
|
|
href={resolve('/groups')}
|
|
|
|
|
|
data-testid="appshell-user-menu-groups-item"
|
|
|
|
|
|
onclick={() => (menuOpen = false)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="user-group" />
|
|
|
|
|
|
<span>{t('user_menu.manage_groups', 'Manage groups')}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
{/if}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<a
|
|
|
|
|
|
class="user-menu-item"
|
|
|
|
|
|
href={resolve('/profile')}
|
|
|
|
|
|
data-testid="appshell-user-menu-profile-item"
|
|
|
|
|
|
onclick={() => (menuOpen = false)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="user-circle" /> <span>{t('user_menu.profile', 'My profile')}</span>
|
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-divider"></div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-item user-menu-item--lang">
|
|
|
|
|
|
<Icon name="globe" />
|
|
|
|
|
|
<span>{t('settings.language', 'Language')}</span>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<div
|
|
|
|
|
|
class="lang-selector"
|
|
|
|
|
|
class:lang-selector--open={langOpen}
|
|
|
|
|
|
data-testid="appshell-lang-menu"
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="lang-selector__toggle"
|
|
|
|
|
|
aria-haspopup="listbox"
|
|
|
|
|
|
aria-expanded={langOpen}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-lang-toggle-btn"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
langOpen = !langOpen;
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="lang-selector__code">{(currentLang.code as string).toUpperCase()}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name="chevron-down" class="lang-selector__arrow" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{#if langOpen}
|
|
|
|
|
|
<ul class="lang-selector__dropdown" role="listbox">
|
|
|
|
|
|
{#each LANGUAGES as lang (lang.code)}
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="lang-option"
|
|
|
|
|
|
class:lang-option--active={lang.code === i18n.locale}
|
|
|
|
|
|
role="option"
|
|
|
|
|
|
aria-selected={lang.code === i18n.locale}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid={`appshell-lang-${lang.code}-option`}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
chooseLocale(lang.code);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="lang-option__flag">{lang.flag}</span>
|
|
|
|
|
|
<span class="lang-option__name">{lang.name}</span>
|
|
|
|
|
|
{#if lang.code === i18n.locale}
|
|
|
|
|
|
<Icon name="check" class="lang-option__check" />
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-item user-menu-item--theme">
|
|
|
|
|
|
<Icon name="adjust" />
|
|
|
|
|
|
<span>{t('user_menu.appearance', 'Appearance')}</span>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="theme-segmented"
|
|
|
|
|
|
role="radiogroup"
|
|
|
|
|
|
aria-label={t('user_menu.appearance', 'Appearance')}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-theme-toggle"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
>
|
|
|
|
|
|
{#each THEMES as th (th.mode)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="theme-segmented__opt"
|
|
|
|
|
|
class:theme-segmented__opt--active={theme.current === th.mode}
|
|
|
|
|
|
role="radio"
|
|
|
|
|
|
aria-checked={theme.current === th.mode}
|
|
|
|
|
|
title={th.label}
|
|
|
|
|
|
aria-label={th.label}
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid={`appshell-theme-${th.mode}-option`}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
onclick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
theme.set(th.mode);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon name={th.icon} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{/each}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="user-menu-item"
|
|
|
|
|
|
data-testid="appshell-user-menu-about-item"
|
|
|
|
|
|
onclick={openAbout}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="info-circle" /> <span>{t('user_menu.about', 'About OxiCloud')}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="user-menu-divider"></div>
|
|
|
|
|
|
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="user-menu-item user-menu-logout"
|
|
|
|
|
|
data-testid="appshell-user-menu-logout-btn"
|
|
|
|
|
|
onclick={onLogout}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<Icon name="sign-out-alt" /> <span>{t('actions.logout', 'Log out')}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-17 17:06:30 -06:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="content-area">
|
|
|
|
|
|
{@render children()}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{#snippet avatar(large: boolean)}
|
|
|
|
|
|
{#if avatarPhoto}
|
|
|
|
|
|
<img
|
|
|
|
|
|
class="avatar avatar--photo"
|
|
|
|
|
|
class:avatar--lg={large}
|
|
|
|
|
|
src={avatarPhoto}
|
|
|
|
|
|
alt={t('user_menu.title', 'User menu')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
<span class="avatar avatar--c{avatarColor}" class:avatar--lg={large}>{initials}</span>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
{/snippet}
|
|
|
|
|
|
|
|
|
|
|
|
{#if aboutOpen}
|
|
|
|
|
|
<!-- About OxiCloud modal -->
|
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
|
|
<div class="about-overlay" onclick={(e) => e.target === e.currentTarget && (aboutOpen = false)}>
|
|
|
|
|
|
<div class="about-modal" role="dialog" aria-modal="true" aria-labelledby="about-modal-title">
|
|
|
|
|
|
<div class="about-modal__logo">
|
2026-06-20 14:42:10 +02:00
|
|
|
|
<svg viewBox="95 67 320 320" aria-hidden="true">
|
2026-06-17 22:07:18 -06:00
|
|
|
|
<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>
|
|
|
|
|
|
<h2 id="about-modal-title" class="about-modal__title">OxiCloud</h2>
|
|
|
|
|
|
<div class="about-modal__version">{appVersion || 'v…'}</div>
|
|
|
|
|
|
<p class="about-modal__desc">
|
|
|
|
|
|
{t(
|
|
|
|
|
|
'user_menu.about_description',
|
|
|
|
|
|
'Cloud storage platform built with Rust & Clean Architecture. Fast, secure, and private.'
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div class="about-modal__tech">
|
|
|
|
|
|
<span class="about-modal__badge">Rust</span>
|
|
|
|
|
|
<span class="about-modal__badge">Axum</span>
|
|
|
|
|
|
<span class="about-modal__badge">PostgreSQL</span>
|
|
|
|
|
|
<span class="about-modal__badge">Clean Architecture</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="about-modal__links">
|
|
|
|
|
|
<a
|
|
|
|
|
|
class="about-modal__link"
|
|
|
|
|
|
href="https://github.com/AtalayaLabs/OxiCloud/"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-about-github-link"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name="github" /> GitHub
|
|
|
|
|
|
</a>
|
|
|
|
|
|
<a
|
|
|
|
|
|
class="about-modal__link"
|
|
|
|
|
|
href="https://github.com/AtalayaLabs/OxiCloud/blob/main/LICENSE"
|
|
|
|
|
|
target="_blank"
|
|
|
|
|
|
rel="noopener"
|
2026-06-21 20:03:32 -06:00
|
|
|
|
data-testid="appshell-about-license-link"
|
2026-06-17 22:07:18 -06:00
|
|
|
|
>
|
|
|
|
|
|
<Icon name="file-alt" />
|
|
|
|
|
|
{t('user_menu.mit_license', 'MIT License')}
|
|
|
|
|
|
</a>
|
|
|
|
|
|
</div>
|
2026-06-21 20:03:32 -06:00
|
|
|
|
<button
|
|
|
|
|
|
class="about-modal__close"
|
|
|
|
|
|
data-testid="appshell-about-close-btn"
|
|
|
|
|
|
onclick={() => (aboutOpen = false)}
|
|
|
|
|
|
>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
{t('actions.close', 'Close')}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
2026-06-19 22:56:00 +00:00
|
|
|
|
{#if palette.component}
|
|
|
|
|
|
{@const CommandPalette = palette.component}
|
|
|
|
|
|
<CommandPalette autoOpen />
|
|
|
|
|
|
{/if}
|
2026-06-17 22:07:18 -06:00
|
|
|
|
|
2026-06-17 17:06:30 -06:00
|
|
|
|
<style>
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/* Body becomes the sidebar+main flex row only while the shell is mounted. */
|
2026-06-17 17:06:30 -06:00
|
|
|
|
:global(body) {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/* Clear (×) button sits left of the submit button inside the search field. */
|
|
|
|
|
|
.search-clear {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
right: 44px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
|
|
|
|
|
width: 28px;
|
|
|
|
|
|
height: 28px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
cursor: pointer;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.search-clear:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: calc(100% + 4px);
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
2026-07-20 22:15:08 +02:00
|
|
|
|
/* Search suggestions render above `.page-sticky-header` — otherwise the
|
|
|
|
|
|
dropdown clips under the action bar on the content pages. Design-token
|
|
|
|
|
|
`--z-dropdown` (1000) sits above `--z-sticky` (100) by construction. */
|
|
|
|
|
|
z-index: var(--z-dropdown);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0.25rem;
|
|
|
|
|
|
background: var(--color-bg-surface);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
border: 1px solid var(--color-border);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
border-radius: var(--radius-lg, var(--radius-md));
|
|
|
|
|
|
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
|
|
|
|
|
|
max-height: 24rem;
|
|
|
|
|
|
overflow: auto;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.suggest__item,
|
|
|
|
|
|
.suggest__all {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 0.5rem 0.6rem;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
border: none;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
background: none;
|
|
|
|
|
|
color: var(--color-text);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
cursor: pointer;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
text-align: left;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest__item:hover,
|
|
|
|
|
|
.suggest__all:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest__all {
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
border-top: 1px solid var(--color-border);
|
|
|
|
|
|
margin-top: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest__icon {
|
|
|
|
|
|
color: var(--color-text-muted);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.suggest__name {
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest__busy {
|
|
|
|
|
|
padding: 0.6rem;
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-progress {
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
margin-top: 0.35rem;
|
|
|
|
|
|
border-radius: var(--radius-pill, 999px);
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-progress__fill {
|
|
|
|
|
|
height: 100%;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
background: var(--color-accent);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
transition: width 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-progress-detail {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
margin-top: 0.25rem;
|
|
|
|
|
|
font-size: var(--text-xs, 0.75rem);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notif-item-current {
|
|
|
|
|
|
margin-top: 0.15rem;
|
|
|
|
|
|
font-size: var(--text-xs, 0.75rem);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Bell "ring" animation, replayed when bellRinging toggles on. */
|
|
|
|
|
|
.notif-bell-btn.ring :global(svg),
|
|
|
|
|
|
.notif-bell-btn.ring :global(i) {
|
|
|
|
|
|
transform-origin: top center;
|
|
|
|
|
|
animation: bell-ring 0.9s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes bell-ring {
|
|
|
|
|
|
0%,
|
|
|
|
|
|
100% {
|
|
|
|
|
|
transform: rotate(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
10%,
|
|
|
|
|
|
30%,
|
|
|
|
|
|
50% {
|
|
|
|
|
|
transform: rotate(12deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
20%,
|
|
|
|
|
|
40%,
|
|
|
|
|
|
60% {
|
|
|
|
|
|
transform: rotate(-12deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
70% {
|
|
|
|
|
|
transform: rotate(6deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
80% {
|
|
|
|
|
|
transform: rotate(-6deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Avatar: deterministic coloured-initials vignette, or the uploaded photo. */
|
|
|
|
|
|
.avatar {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
background: var(--color-accent-gradient, var(--color-accent));
|
2026-06-17 17:06:30 -06:00
|
|
|
|
color: var(--color-on-accent);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
font-weight: var(--weight-bold);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--photo {
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Colour buckets mirror userVignette's .uv-color-0..4 shared palette. */
|
|
|
|
|
|
.avatar--c0 {
|
|
|
|
|
|
background: var(--color-badge-indigo-bg);
|
|
|
|
|
|
color: var(--color-badge-indigo-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--c1 {
|
|
|
|
|
|
background: var(--color-badge-green-bg);
|
|
|
|
|
|
color: var(--color-badge-green-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--c2 {
|
|
|
|
|
|
background: var(--color-badge-orange-bg);
|
|
|
|
|
|
color: var(--color-badge-orange-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--c3 {
|
|
|
|
|
|
background: var(--color-badge-blue-bg);
|
|
|
|
|
|
color: var(--color-badge-blue-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--c4 {
|
|
|
|
|
|
background: var(--color-badge-amber-bg);
|
|
|
|
|
|
color: var(--color-badge-amber-text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.avatar--lg {
|
|
|
|
|
|
width: 44px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
font-size: var(--text-base);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-menu-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
|
padding: var(--space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-menu-id {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-menu-name {
|
|
|
|
|
|
font-weight: var(--weight-semibold);
|
|
|
|
|
|
color: var(--color-text-heading);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-menu-email {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-menu-item--lang,
|
|
|
|
|
|
.user-menu-item--theme {
|
|
|
|
|
|
cursor: default;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
/* Custom language selector — flag + native name + active checkmark. */
|
|
|
|
|
|
.lang-selector {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-selector__toggle {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
|
padding: var(--space-1) var(--space-2);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: var(--text-sm);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
.lang-selector__toggle:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-selector__code {
|
|
|
|
|
|
font-weight: var(--weight-semibold);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:global(.lang-selector__arrow) {
|
|
|
|
|
|
font-size: var(--text-xs, 0.7rem);
|
|
|
|
|
|
transition: transform 0.15s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-selector--open :global(.lang-selector__arrow) {
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-selector__dropdown {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: calc(100% + 4px);
|
|
|
|
|
|
right: 0;
|
2026-07-20 22:15:08 +02:00
|
|
|
|
/* Sits above `--z-sticky` for the same reason as `.suggest` above. */
|
|
|
|
|
|
z-index: var(--z-dropdown);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
min-width: 12rem;
|
|
|
|
|
|
max-height: 18rem;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0.25rem;
|
|
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-lg, var(--radius-md));
|
|
|
|
|
|
box-shadow: var(--shadow-lg, 0 10px 30px var(--color-overlay-shadow));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:global([dir='rtl']) .lang-selector__dropdown {
|
|
|
|
|
|
right: auto;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-option {
|
|
|
|
|
|
display: flex;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
align-items: center;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
gap: 0.6rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding: 0.45rem 0.55rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
background: none;
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
font-size: var(--text-sm);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-option:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-option--active {
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
font-weight: var(--weight-semibold);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lang-option__name {
|
|
|
|
|
|
flex: 1;
|
2026-06-17 17:06:30 -06:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-17 22:07:18 -06:00
|
|
|
|
:global(.lang-option__check) {
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* About OxiCloud modal. */
|
|
|
|
|
|
.about-overlay {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
inset: 0;
|
2026-06-19 22:52:06 +00:00
|
|
|
|
z-index: var(--z-modal);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
background: var(--color-overlay);
|
|
|
|
|
|
animation: about-fade 0.18s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-3);
|
|
|
|
|
|
width: min(92vw, 26rem);
|
|
|
|
|
|
padding: var(--space-6) var(--space-5);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
background: var(--color-bg-surface);
|
|
|
|
|
|
color: var(--color-text);
|
2026-06-17 22:07:18 -06:00
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
box-shadow: var(--shadow-xl);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
animation: about-pop 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__logo {
|
2026-06-20 14:42:10 +02:00
|
|
|
|
/* 73px (not 64) so the cloud keeps its rendered scale after the viewBox
|
|
|
|
|
|
grew 280→320 to stop clipping its left bulge: 73/320 ≈ 64/280. */
|
|
|
|
|
|
width: 73px;
|
|
|
|
|
|
height: 73px;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
color: var(--color-accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__logo svg {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
fill: currentColor;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__title {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: var(--text-xl, 1.5rem);
|
|
|
|
|
|
color: var(--color-text-heading);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__version {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__desc {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-muted);
|
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__tech {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: var(--space-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__badge {
|
|
|
|
|
|
padding: var(--space-1) var(--space-2);
|
|
|
|
|
|
border-radius: var(--radius-pill, 999px);
|
|
|
|
|
|
background: var(--color-bg-muted);
|
|
|
|
|
|
color: var(--color-text-secondary, var(--color-text-muted));
|
|
|
|
|
|
font-size: var(--text-xs, 0.75rem);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__links {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: var(--space-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__link {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--space-1);
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__link:hover {
|
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__close {
|
|
|
|
|
|
margin-top: var(--space-2);
|
|
|
|
|
|
padding: var(--space-2) var(--space-5);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
background: var(--color-bg-input);
|
|
|
|
|
|
color: var(--color-text);
|
2026-06-17 17:06:30 -06:00
|
|
|
|
cursor: pointer;
|
2026-06-17 22:07:18 -06:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.about-modal__close:hover {
|
|
|
|
|
|
background: var(--color-bg-hover);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes about-fade {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes about-pop {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: scale(0.96);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: scale(1);
|
|
|
|
|
|
}
|
2026-06-17 17:06:30 -06:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|