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,112 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
albumId: string;
|
||||
onClose: () => void;
|
||||
onSelected?: (album: Album) => void;
|
||||
}
|
||||
|
||||
let { albumId, onClose, onSelected }: Props = $props();
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
loading = true;
|
||||
error = '';
|
||||
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`)
|
||||
.then((data) => (photos = data.photos))
|
||||
.catch((e) => (error = (e as Error).message))
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
|
||||
async function choose(photo: Photo) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const album = await albums.setCover(albumId, photo.id);
|
||||
onSelected?.(album);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Выбор обложки"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
onkeydown={onKey}
|
||||
>
|
||||
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-2xl flex-col rounded-xl border">
|
||||
<div class="border-border flex items-center justify-between border-b px-4 py-3">
|
||||
<h2 class="font-semibold">Выбрать обложку</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
||||
aria-label="Закрыть"
|
||||
title="Закрыть"
|
||||
onclick={onClose}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
|
||||
{#if loading}
|
||||
<p class="text-text-muted text-sm">Загрузка…</p>
|
||||
{:else if photos.length === 0}
|
||||
<p class="text-text-muted text-sm">В альбоме нет фотографий.</p>
|
||||
{:else}
|
||||
<ul class="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5">
|
||||
{#each photos as photo (photo.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
class="border-border bg-bg-muted block aspect-square w-full overflow-hidden rounded-lg border hover:border-primary disabled:opacity-50"
|
||||
onclick={() => choose(photo)}
|
||||
>
|
||||
{#if photo.thumb_url}
|
||||
<img
|
||||
src={photo.thumb_url}
|
||||
alt={photo.original_filename}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-text-subtle flex h-full w-full items-center justify-center text-xs">
|
||||
обработка…
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user