Files
ds-galo/frontend/src/lib/components/AppShell.svelte
T
iu 50b9983be4 feat(web): сохранять размер миниатюр при скрытых боковых панелях
В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает
число колонок так, чтобы размер миниатюры остался прежним, а в строке
помещалось больше фото. Каркас публикует ширину, которую занимают панели в
текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка
использует её для пересчёта, а навигация курсора стрелками учитывает
реальное число колонок.
2026-10-20 21:38:02 +03:00

370 lines
13 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Avatar from './Avatar.svelte';
import Emblem from './Emblem.svelte';
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 {
children: Snippet;
rail?: Snippet;
panel?: Snippet;
panelVisible?: boolean;
/** Показывать переключатель скрытия боковых панелей (режим просмотра альбома). */
panelsToggle?: boolean;
}
let { children, rail, panel, panelVisible = true, panelsToggle = false }: Props = $props();
let railOpen = $state(false);
let panelOpen = $state(false);
const RAIL = { min: 176, max: 480, key: 'rail-width', def: 240 };
const PANEL = { min: 220, max: 560, key: 'panel-width', def: 288 };
// Ширина разделителя (w-1) между панелью и содержимым.
const HANDLE_PX = 4;
let railWidth = $state(RAIL.def);
let panelWidth = $state(PANEL.def);
let isFullscreen = $state(false);
let isWide = $state(false);
let isExtraWide = $state(false);
// Ширина, которую занимают панели в текущей раскладке: рейл и панель
// встают рядом с содержимым на широких экранах, на узких — поверх него.
const reserved = $derived(
(isWide ? railWidth + HANDLE_PX : 0) +
(isExtraWide && panel && panelVisible ? panelWidth + HANDLE_PX : 0)
);
$effect(() => {
// Режим скрытия действует только там, где он доступен (просмотр альбома).
if (!panelsToggle) panels.reset();
});
$effect(() => {
panels.reserved = reserved;
});
onMount(() => {
const r = parseInt(localStorage.getItem(RAIL.key) ?? '', 10);
if (r >= RAIL.min && r <= RAIL.max) railWidth = r;
const p = parseInt(localStorage.getItem(PANEL.key) ?? '', 10);
if (p >= PANEL.min && p <= PANEL.max) panelWidth = p;
const onFs = () => (isFullscreen = !!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFs);
onFs();
const mqLg = window.matchMedia('(min-width: 1024px)');
const mqXl = window.matchMedia('(min-width: 1280px)');
const sync = () => {
isWide = mqLg.matches;
isExtraWide = mqXl.matches;
};
sync();
mqLg.addEventListener('change', sync);
mqXl.addEventListener('change', sync);
return () => {
document.removeEventListener('fullscreenchange', onFs);
mqLg.removeEventListener('change', sync);
mqXl.removeEventListener('change', sync);
};
});
async function toggleFullscreen() {
try {
if (document.fullscreenElement) await document.exitFullscreen();
else await document.documentElement.requestFullscreen();
} catch {
/* недоступно */
}
}
function startResize(side: 'left' | 'right', ev: PointerEvent) {
ev.preventDefault();
const cfg = side === 'left' ? RAIL : PANEL;
const startX = ev.clientX;
const startW = side === 'left' ? railWidth : panelWidth;
const prevCursor = document.body.style.cursor;
const prevSelect = document.body.style.userSelect;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
const move = (e: PointerEvent) => {
const dx = e.clientX - startX;
const raw = side === 'left' ? startW + dx : startW - dx;
const v = Math.min(cfg.max, Math.max(cfg.min, raw));
if (side === 'left') railWidth = v;
else panelWidth = v;
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
document.body.style.cursor = prevCursor;
document.body.style.userSelect = prevSelect;
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
function nudge(side: 'left' | 'right', ev: KeyboardEvent) {
const cfg = side === 'left' ? RAIL : PANEL;
let delta = 0;
if (ev.key === 'ArrowLeft') delta = side === 'left' ? -16 : 16;
else if (ev.key === 'ArrowRight') delta = side === 'left' ? 16 : -16;
else return;
ev.preventDefault();
if (side === 'left') railWidth = Math.min(cfg.max, Math.max(cfg.min, railWidth + delta));
else panelWidth = Math.min(cfg.max, Math.max(cfg.min, panelWidth + delta));
localStorage.setItem(cfg.key, String(side === 'left' ? railWidth : panelWidth));
}
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(() => {});
}
auth.clear();
goto('/login');
}
</script>
<svelte:window onkeydown={onKeydown} />
<div class="bg-canvas text-text flex h-screen flex-col overflow-hidden">
<header
class="border-border bg-chrome flex shrink-0 items-center justify-between gap-2 border-b px-2 py-0.5 sm:px-3"
>
<div class="flex min-w-0 items-center gap-1">
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 lg:hidden"
aria-label="Альбомы"
title="Альбомы"
onclick={() => (railOpen = true)}
>
<Icon name="menu" class="size-4" />
</button>
<a href="/" class="inline-flex items-center gap-1.5 truncate text-sm font-semibold">
<Emblem class="text-primary size-4" />
Galo
</a>
</div>
<div class="flex items-center gap-1 sm:gap-1.5">
{#if panel && panelVisible}
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1 xl:hidden"
aria-label="Панель"
title="Панель"
onclick={() => (panelOpen = true)}
>
<Icon name="wallpaper" class="size-4" />
</button>
{/if}
{#if auth.isAuthenticated}
<a
href="/profile"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-xs"
title="Профиль"
>
<Avatar name={auth.user?.name} url={auth.user?.avatar_url} size="size-6" />
<span class="hidden sm:inline">{auth.user?.name ?? 'Профиль'}</span>
</a>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
onclick={logout}
>
<Icon name="logout" class="size-4" />
<span class="hidden sm:inline">Выйти</span>
</button>
{:else}
<a
href="/login"
class="text-text-muted hover:text-text inline-flex items-center gap-1 text-xs"
>
<Icon name="login" class="size-4" />
<span class="hidden sm:inline">Войти</span>
</a>
{/if}
<ThemeSwitcher />
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
title={isFullscreen ? 'Выйти из полноэкранного режима' : 'На весь экран'}
aria-pressed={isFullscreen}
onclick={toggleFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
</div>
</header>
<div class="relative flex min-h-0 flex-1">
{#if railOpen}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"
aria-label="Закрыть панель альбомов"
onclick={() => (railOpen = false)}
></button>
{/if}
<aside
class={railClass}
style="--rail-w: {railWidth}px"
aria-label="Альбомы"
inert={panels.hidden && !panels.railPeek && !railOpen}
onmouseleave={(e) => onAsideLeave(e, 'left')}
>
{#if rail}{@render rail()}{/if}
</aside>
{#if !panels.hidden}
<button
type="button"
class="{handle} lg:block"
aria-label="Изменить ширину панели альбомов"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)}
></button>
{/if}
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
{@render children()}
</main>
{#if panel && panelVisible}
{#if panelOpen}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50 xl:hidden"
aria-label="Закрыть панель"
onclick={() => (panelOpen = false)}
></button>
{/if}
{#if !panels.hidden}
<button
type="button"
class="{handle} xl:block"
aria-label="Изменить ширину боковой панели"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)}
></button>
{/if}
<aside
class={panelClass}
style="--panel-w: {panelWidth}px"
aria-label="Панель"
inert={panels.hidden && !panels.panelPeek && !panelOpen}
onmouseleave={(e) => onAsideLeave(e, 'right')}
>
{@render panel()}
</aside>
{/if}
{#if panels.hidden}
<div
class="group absolute inset-y-0 left-0 z-20 hidden w-1.5 lg:block"
aria-hidden="true"
onmouseenter={() => (panels.railPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{#if panel && panelVisible}
<div
class="group absolute inset-y-0 right-0 z-20 hidden w-1.5 xl:block"
aria-hidden="true"
onmouseenter={() => (panels.panelPeek = true)}
>
<span class="block h-full w-full transition-colors group-hover:bg-primary/40"></span>
</div>
{/if}
{/if}
</div>
</div>