Files
ds-galo/frontend/src/lib/components/AlbumRail.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

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>