feat: photo multiselect, move/copy, trash and compact chrome

- backend: soft delete + trash (photos.deleted_at, migration 000005), GET /trash, restore, permanent delete with storage-key refcount (S3 objects removed only when no other album references them); batch endpoints photo-batch/{delete,move,copy} - metadata only, copies clone rows sharing storage keys
- frontend: Ctrl/Shift multiselect; right-panel 'Действия' as icon text rows (Открыть (просмотр), Переместить, Копировать, Удалить); drag selected photos onto rail album -> TransferDialog; /trash route; trash entry as last item in the left rail; empty right panel hidden on overview
- frontend: compact top bars (app header and viewer), compact theme switcher
- openspec: sync lightroom-workspace-ui artifacts (photos/ui-workspace specs, design #20-24, tasks 9.11-9.17)
This commit is contained in:
iu
2026-10-10 22:13:17 +03:00
parent 78996bc053
commit 2d6a3fea1a
27 changed files with 1097 additions and 85 deletions
+53 -2
View File
@@ -5,9 +5,36 @@
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';
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 */
}
}
$effect(() => {
if (auth.isAuthenticated && !albums.loaded) albums.load();
@@ -42,11 +69,16 @@
<li>
<a
href="/albums/{album.id}"
class="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors {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'}"
: '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)}
>
<span
class="bg-bg-muted border-border flex size-9 shrink-0 items-center justify-center overflow-hidden rounded border"
@@ -69,6 +101,25 @@
</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>
</a>
</div>
{/if}
{#if creating}
<CreateAlbumDialog
onClose={() => (creating = false)}