feat: lightroom-style workspace UI with album covers

Backend:
- expose cover thumbnail URL on album responses (LEFT JOIN + presign)
- auto-assign cover only when empty; FK cover_photo_id -> photos ON DELETE SET NULL
- add photos_count / members_count to album responses; cover repo test

Frontend:
- split layouts into (app) shell (resizable rail + contextual panel) and (public)
- album rail + create-album dialog; album panel with cover picker / make-cover
- photo viewer route with filmstrip, replacing lightbox; share reuses it
- grid density slider, toolbar + drag-and-drop upload, dark graphite default

OpenSpec: add lightroom-workspace-ui change (proposal, design, specs, tasks)
This commit is contained in:
iu
2026-10-10 18:15:13 +03:00
parent b90e6b0267
commit 304cfedb75
47 changed files with 1942 additions and 601 deletions
+200
View File
@@ -0,0 +1,200 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authApi } from '$lib/api';
import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
rail?: Snippet;
panel?: Snippet;
}
let { children, rail, panel }: 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 };
let railWidth = $state(RAIL.def);
let panelWidth = $state(PANEL.def);
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;
});
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';
async function logout() {
if (auth.refresh) {
await authApi.logout(auth.refresh).catch(() => {});
}
auth.clear();
goto('/login');
}
</script>
<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-3 border-b px-3 py-2.5 sm:px-4"
>
<div class="flex min-w-0 items-center gap-2">
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5 lg:hidden"
aria-label="Альбомы"
title="Альбомы"
onclick={() => (railOpen = true)}
>
<Icon name="menu" />
</button>
<a href="/" class="inline-flex items-center gap-2 truncate text-lg font-semibold">
<Icon name="photo_camera" />
Galo
</a>
</div>
<div class="flex items-center gap-2 sm:gap-3">
{#if panel}
<button
type="button"
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1.5 xl:hidden"
aria-label="Панель"
title="Панель"
onclick={() => (panelOpen = true)}
>
<Icon name="wallpaper" />
</button>
{/if}
{#if auth.isAuthenticated}
<span class="text-text-muted hidden text-sm sm:inline">{auth.user?.name ?? ''}</span>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center gap-1.5 text-sm"
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.5 text-sm"
>
<Icon name="login" class="size-4" />
<span class="hidden sm:inline">Войти</span>
</a>
{/if}
<ThemeSwitcher />
</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="border-border bg-chrome absolute inset-y-0 left-0 z-40 w-60 -translate-x-full overflow-y-auto border-r transition-transform lg:static lg:z-auto lg:w-[var(--rail-w)] lg:translate-x-0 {railOpen
? 'translate-x-0'
: ''}"
style="--rail-w: {railWidth}px"
aria-label="Альбомы"
>
{#if rail}{@render rail()}{/if}
</aside>
<button
type="button"
class="{handle} lg:block"
aria-label="Изменить ширину панели альбомов"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('left', e)}
onkeydown={(e) => nudge('left', e)}
></button>
<main class="bg-canvas min-h-0 flex-1 overflow-y-auto">
{@render children()}
</main>
{#if panel}
{#if panelOpen}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50 xl:hidden"
aria-label="Закрыть панель"
onclick={() => (panelOpen = false)}
></button>
{/if}
<button
type="button"
class="{handle} xl:block"
aria-label="Изменить ширину боковой панели"
title="Потяните, чтобы изменить ширину"
onpointerdown={(e) => startResize('right', e)}
onkeydown={(e) => nudge('right', e)}
></button>
<aside
class="border-border bg-chrome absolute inset-y-0 right-0 z-40 w-72 translate-x-full overflow-y-auto border-l transition-transform xl:static xl:z-auto xl:w-[var(--panel-w)] xl:translate-x-0 {panelOpen
? 'translate-x-0'
: ''}"
style="--panel-w: {panelWidth}px"
aria-label="Панель"
>
{@render panel()}
</aside>
{/if}
</div>
</div>