Files
ds-galo/frontend/src/lib/components/AlbumPanel.svelte
T
iu 61b45b2742 feat: context menus, unified photo panel, tags, album trash and counters
- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count
- frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs
- openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
2026-10-10 22:45:41 +03:00

232 lines
6.5 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoPanel from './PhotoPanel.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.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 selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
async function deleteSelected() {
deletingSelected = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDeleteSelected = false;
} catch (e) {
error = (e as Error).message;
} finally {
deletingSelected = false;
}
}
function openSelected() {
const first = selectedItems[0];
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
function onRenamed(updated: Photo) {
photo = updated;
if (selectedItems.some((p) => p.id === updated.id)) selection.replace(updated);
}
function onDeleted() {
selection.clear();
}
$effect(() => {
const id = albumId;
error = '';
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';
</script>
{#snippet selectionActions()}
<div class="border-border border-b p-4">
<p class="text-text-subtle mb-1 text-xs font-semibold tracking-wide uppercase">Действия</p>
<nav class="space-y-0.5">
<button type="button" class={navLink} onclick={openSelected}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={() => (pendingDeleteSelected = true)}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
</nav>
</div>
{/snippet}
{#if panelPhoto}
<PhotoPanel
photo={panelPhoto}
{role}
viewer={!!photoId}
onOpen={openSelected}
onRenamed={onRenamed}
onDeleted={onDeleted}
/>
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
<div>
<PanelSection id="album-cover" title="Обложка">
<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}
</PanelSection>
<PanelSection id="album-info" title="Информация">
<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}
</PanelSection>
<PanelSection id="album-nav" title="Навигация">
<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>
</PanelSection>
</div>
{#if picking}
<CoverPicker
albumId={album.id}
onClose={() => (picking = false)}
onSelected={(updated) => (album = updated)}
/>
{/if}
{/if}
{#if pendingDeleteSelected}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selectedItems.length} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingSelected}
onConfirm={deleteSelected}
onClose={() => (pendingDeleteSelected = false)}
/>
{/if}