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,165 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { api } from '$lib/api';
|
||||
import Icon from './Icon.svelte';
|
||||
import CoverPicker from './CoverPicker.svelte';
|
||||
import Comments from './Comments.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
|
||||
let album = $state<Album | null>(null);
|
||||
let role = $state('');
|
||||
let error = $state('');
|
||||
let picking = $state(false);
|
||||
|
||||
let photo = $state<Photo | null>(null);
|
||||
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
error = '';
|
||||
if (!id || photoId) {
|
||||
if (!id) {
|
||||
album = null;
|
||||
role = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||
.then((data) => {
|
||||
album = data.album;
|
||||
role = data.role;
|
||||
albums.upsert(data.album);
|
||||
})
|
||||
.catch((e) => (error = (e as Error).message));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const id = photoId;
|
||||
if (!id) {
|
||||
photo = null;
|
||||
return;
|
||||
}
|
||||
api<{ photo: Photo }>(`/photos/${id}`)
|
||||
.then((data) => (photo = data.photo))
|
||||
.catch((e) => (error = (e as Error).message));
|
||||
});
|
||||
|
||||
async function resetCover() {
|
||||
if (!album) return;
|
||||
try {
|
||||
album = await albums.setCover(album.id, null);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const navLink =
|
||||
'text-text-muted hover:bg-bg-muted hover:text-text flex items-center gap-2 rounded-md px-2 py-1.5 text-sm';
|
||||
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
|
||||
</script>
|
||||
|
||||
{#if photoId}
|
||||
{#if photo}
|
||||
<div class="space-y-4 p-4">
|
||||
<h2 class="text-text truncate text-sm font-semibold" title={photo.original_filename}>
|
||||
{photo.original_filename}
|
||||
</h2>
|
||||
<div>
|
||||
<div class={metaRow}><span>Автор</span><span class="text-text">{photo.uploader.name}</span></div>
|
||||
{#if photo.taken_at}
|
||||
<div class={metaRow}>
|
||||
<span>Снято</span>
|
||||
<span class="text-text">{new Date(photo.taken_at).toLocaleString()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class={metaRow}>
|
||||
<span>Размер</span>
|
||||
<span class="text-text">{photo.width}×{photo.height}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-text-muted mb-1 block text-xs font-semibold tracking-wide uppercase">
|
||||
Комментарии
|
||||
</span>
|
||||
<Comments photoId={photo.id} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if album}
|
||||
<div class="space-y-5 p-4">
|
||||
<div>
|
||||
<span class="text-text-muted mb-2 block text-xs font-semibold tracking-wide uppercase">
|
||||
Обложка
|
||||
</span>
|
||||
<div class="bg-bg-muted border-border aspect-video overflow-hidden rounded-lg border">
|
||||
{#if album.cover_thumb_url}
|
||||
<img src={album.cover_thumb_url} alt="" class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<span class="flex h-full w-full items-center justify-center">
|
||||
<Icon name="image" class="size-10 text-text-subtle" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if canEdit}
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="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"
|
||||
onclick={() => (picking = true)}
|
||||
>
|
||||
<Icon name="wallpaper" class="size-4" />
|
||||
Выбрать обложку
|
||||
</button>
|
||||
{#if album.cover_photo_id}
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex items-center rounded-lg px-3 py-1.5 text-sm"
|
||||
onclick={resetCover}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 class="text-text text-lg font-semibold">{album.title}</h2>
|
||||
{#if album.description}<p class="text-text-muted text-sm">{album.description}</p>{/if}
|
||||
<p class="text-text-subtle mt-1 text-xs">
|
||||
{album.photos_count ?? 0} фото · {album.members_count ?? 0} участников
|
||||
</p>
|
||||
{#if error}<p class="text-danger mt-2 text-sm">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
<nav class="space-y-0.5" aria-label="Навигация по альбому">
|
||||
<a class={navLink} href="/albums/{album.id}/members">
|
||||
<Icon name="group" class="size-4" />
|
||||
Участники
|
||||
</a>
|
||||
{#if canEdit}
|
||||
<a class={navLink} href="/albums/{album.id}/settings">
|
||||
<Icon name="settings" class="size-4" />
|
||||
Настройки
|
||||
</a>
|
||||
<a class={navLink} href="/albums/{album.id}/settings#share">
|
||||
<Icon name="add_link" class="size-4" />
|
||||
Ссылки для просмотра
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if picking}
|
||||
<CoverPicker
|
||||
albumId={album.id}
|
||||
onClose={() => (picking = false)}
|
||||
onSelected={(updated) => (album = updated)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user