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 @@ } + +
{/if} - + {#if !panels.hidden} + + {/if}
{@render children()} @@ -214,23 +296,44 @@ onclick={() => (panelOpen = false)} > {/if} - + {#if !panels.hidden} + + {/if} {/if} + + {#if panels.hidden} + + {#if panel && panelVisible} + + {/if} + {/if}
diff --git a/frontend/src/lib/components/DensityControl.svelte b/frontend/src/lib/components/DensityControl.svelte index e379bf0..8d80642 100644 --- a/frontend/src/lib/components/DensityControl.svelte +++ b/frontend/src/lib/components/DensityControl.svelte @@ -8,7 +8,7 @@ density.stepSize(-1)} class="size-7" @@ -26,7 +26,7 @@ /> = DENSITY_MAX} onclick={() => density.stepSize(1)} class="size-7" diff --git a/frontend/src/lib/icons/registry.ts b/frontend/src/lib/icons/registry.ts index 61c6548..f261bce 100644 --- a/frontend/src/lib/icons/registry.ts +++ b/frontend/src/lib/icons/registry.ts @@ -53,7 +53,9 @@ export const icons = { fullscreen_exit: { path: '' }, contrast: { path: '' }, check_all: { path: '' }, - uncheck_all: { path: '' } + uncheck_all: { path: '' }, + left_panel_close: { path: '' }, + left_panel_open: { path: '' } } satisfies Record; export type IconName = keyof typeof icons; diff --git a/frontend/src/lib/stores/panels.svelte.ts b/frontend/src/lib/stores/panels.svelte.ts new file mode 100644 index 0000000..ab90374 --- /dev/null +++ b/frontend/src/lib/stores/panels.svelte.ts @@ -0,0 +1,27 @@ +class PanelsStore { + /** Боковые панели убраны за край, содержимое занимает всю ширину. */ + hidden = $state(false); + /** Левая панель показана у кромки в скрытом режиме. */ + railPeek = $state(false); + /** Правая панель показана у кромки в скрытом режиме. */ + panelPeek = $state(false); + + toggle() { + this.hidden = !this.hidden; + this.railPeek = false; + this.panelPeek = false; + if (this.hidden && typeof document !== 'undefined') { + // Не оставляем фокус внутри панели, которая уезжает за край. + const el = document.activeElement as HTMLElement | null; + if (el?.closest('aside')) el.blur(); + } + } + + reset() { + this.hidden = false; + this.railPeek = false; + this.panelPeek = false; + } +} + +export const panels = new PanelsStore(); diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index f765fe0..b9c658b 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -11,6 +11,7 @@ let { children } = $props(); const showPanel = $derived($page.url.pathname !== '/'); + const albumView = $derived($page.url.pathname.startsWith('/albums/')); // Personal stream: one subscription for the whole session, kept alive across // navigation inside the (app) group. The stream updates the album list @@ -23,7 +24,7 @@ }); - + {#snippet rail()} {/snippet} diff --git a/frontend/src/routes/(app)/albums/[id]/+page.svelte b/frontend/src/routes/(app)/albums/[id]/+page.svelte index 739c20b..c38fb36 100644 --- a/frontend/src/routes/(app)/albums/[id]/+page.svelte +++ b/frontend/src/routes/(app)/albums/[id]/+page.svelte @@ -18,6 +18,7 @@ import { viewerReturn } from '$lib/stores/viewer-return'; import { transfer } from '$lib/stores/transfer.svelte'; import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte'; + import { panels } from '$lib/stores/panels.svelte'; import { SSEClient, type SSEEvent } from '$lib/sse'; import type { Album, Photo } from '$lib/types'; @@ -496,6 +497,18 @@
+ + {#if canUpload}