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:
@@ -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>
|
||||
Reference in New Issue
Block a user