- 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)
152 lines
5.1 KiB
Svelte
152 lines
5.1 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import Icon from './Icon.svelte';
|
|
import CreateAlbumDialog from './CreateAlbumDialog.svelte';
|
|
import { albums } from '$lib/stores/albums.svelte';
|
|
import { auth } from '$lib/stores/auth.svelte';
|
|
import { transfer } from '$lib/stores/transfer.svelte';
|
|
import { albumActions } from '$lib/stores/album-actions.svelte';
|
|
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
|
import { trash } from '$lib/stores/trash.svelte';
|
|
import type { Album } from '$lib/types';
|
|
|
|
const activeId = $derived($page.params.id ?? '');
|
|
const onTrash = $derived($page.url.pathname === '/trash');
|
|
let creating = $state(false);
|
|
let dropId = $state<string | null>(null);
|
|
|
|
function onDragOver(ev: DragEvent, albumId: string) {
|
|
if (!ev.dataTransfer?.types.includes('application/x-galo-photos')) return;
|
|
ev.preventDefault();
|
|
ev.dataTransfer.dropEffect = 'move';
|
|
dropId = albumId;
|
|
}
|
|
|
|
function onDragLeave(albumId: string) {
|
|
if (dropId === albumId) dropId = null;
|
|
}
|
|
|
|
function onDrop(ev: DragEvent, albumId: string) {
|
|
dropId = null;
|
|
const raw = ev.dataTransfer?.getData('application/x-galo-photos');
|
|
if (!raw) return;
|
|
ev.preventDefault();
|
|
try {
|
|
const ids = JSON.parse(raw) as string[];
|
|
transfer.start(ids, albumId);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function onContextMenu(ev: MouseEvent, album: Album) {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
const items: ContextMenuItem[] = [
|
|
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
|
|
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
|
|
];
|
|
contextMenu.show(ev.clientX, ev.clientY, items);
|
|
}
|
|
|
|
$effect(() => {
|
|
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
|
});
|
|
|
|
$effect(() => {
|
|
$page.url.pathname;
|
|
if (auth.isAuthenticated) trash.load();
|
|
});
|
|
</script>
|
|
|
|
<div class="flex h-full flex-col">
|
|
<div class="border-border flex items-center justify-between border-b px-3 py-2">
|
|
<span class="text-text-muted text-xs font-semibold tracking-wide uppercase">Альбомы</span>
|
|
{#if auth.isAuthenticated}
|
|
<button
|
|
type="button"
|
|
class="text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1"
|
|
aria-label="Новый альбом"
|
|
title="Новый альбом"
|
|
onclick={() => (creating = true)}
|
|
>
|
|
<Icon name="add" class="size-4" />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !auth.isAuthenticated}
|
|
<p class="text-text-subtle p-3 text-sm">Войдите, чтобы видеть альбомы.</p>
|
|
{:else if albums.loading && albums.list.length === 0}
|
|
<p class="text-text-subtle p-3 text-sm">Загрузка…</p>
|
|
{:else if albums.list.length === 0}
|
|
<p class="text-text-subtle p-3 text-sm">Альбомов пока нет.</p>
|
|
{:else}
|
|
<ul class="flex-1 space-y-0.5 overflow-y-auto p-2">
|
|
{#each albums.list as album (album.id)}
|
|
<li>
|
|
<a
|
|
href="/albums/{album.id}"
|
|
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {album.id ===
|
|
activeId
|
|
? 'bg-bg-muted text-text'
|
|
: 'text-text-muted hover:bg-bg-muted hover:text-text'} {dropId === album.id
|
|
? 'border-primary ring-primary ring-2'
|
|
: 'border-transparent'}"
|
|
aria-current={album.id === activeId ? 'page' : undefined}
|
|
ondragover={(e) => onDragOver(e, album.id)}
|
|
ondragleave={() => onDragLeave(album.id)}
|
|
ondrop={(e) => onDrop(e, album.id)}
|
|
oncontextmenu={(e) => onContextMenu(e, album)}
|
|
>
|
|
<span
|
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
|
>
|
|
{#if album.cover_thumb_url}
|
|
<img
|
|
src={album.cover_thumb_url}
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
loading="lazy"
|
|
/>
|
|
{:else}
|
|
<Icon name="image" class="size-4 text-text-subtle" />
|
|
{/if}
|
|
</span>
|
|
<span class="truncate">{album.title}</span>
|
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{album.photos_count ?? 0}</span>
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if auth.isAuthenticated}
|
|
<div class="border-border mt-auto border-t p-2">
|
|
<a
|
|
href="/trash"
|
|
class="flex items-center gap-2.5 rounded-md border px-2 py-1.5 text-sm transition-colors {onTrash
|
|
? 'bg-bg-muted text-text border-transparent'
|
|
: 'text-text-muted hover:bg-bg-muted hover:text-text border-transparent'}"
|
|
aria-current={onTrash ? 'page' : undefined}
|
|
>
|
|
<span
|
|
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
|
|
>
|
|
<Icon name="delete" class="size-4 text-text-subtle" />
|
|
</span>
|
|
<span class="truncate">Корзина</span>
|
|
<span class="text-text-subtle ml-auto shrink-0 text-xs">{trash.count}</span>
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if creating}
|
|
<CreateAlbumDialog
|
|
onClose={() => (creating = false)}
|
|
onCreated={(album) => goto(`/albums/${album.id}`)}
|
|
/>
|
|
{/if}
|
|
</div>
|