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
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { api } from '$lib/api';
import Icon from './Icon.svelte';
import type { Photo } from '$lib/types';
interface Props {
photos: Photo[];
index: number;
canEdit?: boolean;
onClose: () => void;
onIndexChange: (index: number) => void;
onMakeCover?: (photo: Photo) => void;
}
let { photos, index, canEdit = false, onClose, onIndexChange, onMakeCover }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
const current = $derived(photos[index]);
$effect(() => {
const id = current?.id;
fresh = null;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
function prev() {
if (index > 0) onIndexChange(index - 1);
}
function next() {
if (index < photos.length - 1) onIndexChange(index + 1);
}
function onKey(ev: KeyboardEvent) {
if (ev.key === 'Escape') onClose();
else if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
}
function onTouchStart(ev: TouchEvent) {
touchX = ev.changedTouches[0].clientX;
}
function onTouchEnd(ev: TouchEvent) {
const dx = ev.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
else if (dx < -50) next();
}
const btn =
'border-border text-text hover:bg-bg-muted inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm';
</script>
<svelte:window onkeydown={onKey} />
<div class="flex h-full flex-col">
<div class="border-border flex shrink-0 items-center justify-between gap-2 border-b px-3 py-2">
<span class="text-text-muted text-sm">{index + 1} / {photos.length}</span>
<div class="flex items-center gap-2">
{#if canEdit}
<button
type="button"
class={btn}
title="Сделать обложкой"
onclick={() => current && onMakeCover?.(current)}
>
<Icon name="wallpaper" class="size-4" />
<span class="hidden sm:inline">Обложка</span>
</button>
{/if}
{#if fresh?.download_url}
<a href={fresh.download_url} class={btn} target="_blank" rel="noreferrer">
<Icon name="download" class="size-4" />
<span class="hidden sm:inline">Скачать</span>
</a>
{/if}
<button type="button" class={btn} aria-label="Закрыть" title="Закрыть" onclick={onClose}>
<Icon name="close" />
</button>
</div>
</div>
<div
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden px-2 py-2"
role="group"
aria-label="Просмотр фото"
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={index === 0}
>
<Icon name="chevron_left" class="size-8" />
</button>
{#if current}
<img
src={fresh?.preview_url ??
fresh?.original_url ??
current.preview_url ??
current.original_url}
alt={current.original_filename}
class="max-h-full max-w-full rounded-lg object-contain"
/>
{/if}
<button
type="button"
class="text-text-muted hover:text-text inline-flex items-center px-1 disabled:opacity-30"
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={index === photos.length - 1}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
<div class="border-border flex shrink-0 gap-2 overflow-x-auto border-t p-2" aria-label="Лента миниатюр">
{#each photos as photo, i (photo.id)}
<button
type="button"
class="h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
? 'border-primary'
: 'border-border'}"
onclick={() => onIndexChange(i)}
aria-label="Фото {i + 1}"
aria-current={i === index ? 'true' : undefined}
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt=""
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<span class="bg-bg-muted block h-full w-full"></span>
{/if}
</button>
{/each}
</div>
</div>