- 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)
232 lines
6.5 KiB
Svelte
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}
|