В скрытом режиме панели больше не растягивают плитки: сетка пересчитывает число колонок так, чтобы размер миниатюры остался прежним, а в строке помещалось больше фото. Каркас публикует ширину, которую занимают панели в текущей раскладке (рейл на широких экранах, панель на сверхшироких), сетка использует её для пересчёта, а навигация курсора стрелками учитывает реальное число колонок.
370 lines
13 KiB
Svelte
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>
|