Files
Oxicloud/frontend/src/lib/components/AppShell.svelte
T

1573 lines
44 KiB
Svelte
Raw Normal View History

2026-06-17 17:06:30 -06:00
<script lang="ts">
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
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';
import { searchResources } from '$lib/api/endpoints/search';
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';
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';
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
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';
import { dialogs } from '$lib/stores/dialogs.svelte';
import { files as filesStore } from '$lib/stores/files.svelte';
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';
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();
// 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 {
/**
* 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;
/** 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[] = [
{ 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'),
icon: 'oxiimport',
section: 'shared-with-me'
2026-06-17 17:06:30 -06: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
];
// 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');
// 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}/`);
}
// 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);
}
// ── 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;
// 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();
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
}
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) {
suggestSeq++;
suggestInflight?.abort();
suggestInflight = null;
2026-06-17 22:07:18 -06:00
suggestions = [];
suggestOpen = false;
return;
}
suggestTimer = setTimeout(async () => {
const seq = ++suggestSeq;
suggestInflight?.abort();
const ctl = new AbortController();
suggestInflight = ctl;
2026-06-17 22:07:18 -06:00
suggestBusy = true;
try {
const r = await searchResources(q, { recursive: true, limit: 9, signal: ctl.signal });
if (seq !== suggestSeq) return; // superseded while awaiting
// 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 {
if (seq !== suggestSeq || ctl.signal.aborted) return;
2026-06-17 22:07:18 -06:00
suggestions = [];
suggestOpen = false;
} finally {
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;
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
);
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);
/** 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;
}
/**
* 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 {
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();
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) => {
// 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;
}
// 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}>
<a href={resolve('/files')} class="logo-container" data-testid="appshell-logo-link">
2026-06-17 17:06:30 -06:00
<div class="logo">
<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>
<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"
class:active={activeLink(link.href)}
class:nav-item--drop-target={isDropTarget && sidebarDropHref === link.href}
href={navHref(link.href)}
data-section={link.section}
data-testid={`appshell-nav-${link.section}-link`}
2026-06-17 17:06:30 -06:00
onclick={() => (sidebarOpen = false)}
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>
{#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}
<!-- 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}
{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}
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')}
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')}
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}
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')}
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')}
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>
<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>
<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 -->
<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"
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')}
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 -->
<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"
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">
{#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}
<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>
<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}
<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>
<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}
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}
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')}
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}
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>
<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>
<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">
<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"
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"
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>
<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}
{#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;
/* 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;
/* 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;
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 {
/* 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>