diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte
index 082535a..5210464 100644
--- a/frontend/src/lib/components/AppShell.svelte
+++ b/frontend/src/lib/components/AppShell.svelte
@@ -7,6 +7,8 @@
import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte';
+ import { density } from '$lib/stores/density.svelte';
+ import { panels } from '$lib/stores/panels.svelte';
import type { Snippet } from 'svelte';
interface Props {
@@ -14,9 +16,11 @@
rail?: Snippet;
panel?: Snippet;
panelVisible?: boolean;
+ /** Показывать переключатель скрытия боковых панелей (режим просмотра альбома). */
+ panelsToggle?: boolean;
}
- let { children, rail, panel, panelVisible = true }: Props = $props();
+ let { children, rail, panel, panelVisible = true, panelsToggle = false }: Props = $props();
let railOpen = $state(false);
let panelOpen = $state(false);
@@ -29,6 +33,11 @@
let isFullscreen = $state(false);
+ $effect(() => {
+ // Режим скрытия действует только там, где он доступен (просмотр альбома).
+ if (!panelsToggle) panels.reset();
+ });
+
onMount(() => {
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
@@ -93,6 +102,75 @@
const handle =
'bg-transparent hover:bg-primary/40 focus-visible:bg-primary/40 hidden w-1 shrink-0 cursor-col-resize p-0 transition-colors outline-none';
+ const railClass = $derived(
+ `border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 overflow-y-auto border-r transition-transform motion-reduce:transition-none lg:w-[var(--rail-w)] ${
+ panels.hidden
+ ? `${railOpen ? 'translate-x-0' : '-translate-x-full'} lg:z-30 ${panels.railPeek ? 'lg:translate-x-0' : 'lg:-translate-x-full'}`
+ : `lg:static lg:z-auto lg:translate-x-0 ${railOpen ? 'translate-x-0' : '-translate-x-full'}`
+ }`
+ );
+
+ const panelClass = $derived(
+ `border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 overflow-y-auto border-l transition-transform motion-reduce:transition-none xl:w-[var(--panel-w)] ${
+ panels.hidden
+ ? `${panelOpen ? 'translate-x-0' : 'translate-x-full'} xl:z-30 ${panels.panelPeek ? 'xl:translate-x-0' : 'xl:translate-x-full'}`
+ : `xl:static xl:z-auto xl:translate-x-0 ${panelOpen ? 'translate-x-0' : 'translate-x-full'}`
+ }`
+ );
+
+ function isTyping(): boolean {
+ const el = document.activeElement as HTMLElement | null;
+ if (!el) return false;
+ const tag = el.tagName;
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
+ }
+
+ /** Как isTyping, но ползунок и служебные input'ы текстом не считаются. */
+ function isTextEntry(): boolean {
+ const el = document.activeElement as HTMLElement | null;
+ if (!el) return false;
+ if (el.isContentEditable) return true;
+ const tag = el.tagName;
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
+ if (tag === 'INPUT') {
+ return !['range', 'checkbox', 'radio', 'button', 'submit', 'reset'].includes(
+ (el as HTMLInputElement).type
+ );
+ }
+ return false;
+ }
+
+ function hasOverlay(): boolean {
+ return !!document.querySelector('[role="dialog"][aria-modal="true"], [role="menu"]');
+ }
+
+ function onKeydown(ev: KeyboardEvent) {
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.repeat) return;
+
+ // `+`/`-` меняют размер миниатюр — как кнопки под регулятором.
+ if (ev.key === '+' || ev.key === '=' || ev.key === '-' || ev.key === '_') {
+ if (isTextEntry() || hasOverlay()) return;
+ ev.preventDefault();
+ density.stepSize(ev.key === '+' || ev.key === '=' ? 1 : -1);
+ return;
+ }
+
+ if (!panelsToggle || !panel || !panelVisible) return;
+ if (ev.key !== 'Tab' || ev.shiftKey) return;
+ if (isTyping() || hasOverlay()) return;
+ ev.preventDefault();
+ panels.toggle();
+ }
+
+ function onAsideLeave(ev: MouseEvent, side: 'left' | 'right') {
+ if (!panels.hidden) return;
+ const el = ev.currentTarget as HTMLElement | null;
+ // Панель с фокусом не прячем — иначе клавиатурный пользователь теряет её.
+ if (el?.contains(document.activeElement)) return;
+ if (side === 'left') panels.railPeek = false;
+ else panels.panelPeek = false;
+ }
+
async function logout() {
if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {});
@@ -102,6 +180,8 @@
}
+