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
+7 -1
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import { page } from '$app/stores';
import AppShell from '$lib/components/AppShell.svelte';
import AlbumRail from '$lib/components/AlbumRail.svelte';
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
import TransferDialog from '$lib/components/TransferDialog.svelte';
let { children } = $props();
const showPanel = $derived($page.url.pathname !== '/');
</script>
<AppShell>
<AppShell panelVisible={showPanel}>
{#snippet rail()}
<AlbumRail />
{/snippet}
@@ -15,3 +19,5 @@
{/snippet}
{@render children()}
</AppShell>
<TransferDialog />
+6 -5
View File
@@ -31,12 +31,13 @@
{#if albums.loading && albums.list.length === 0}
<p class="text-text-muted">Загрузка…</p>
{:else if albums.list.length === 0}
<div class="text-text-muted flex flex-col items-center gap-3 py-16">
<Icon name="photo_library" class="size-16 text-text-subtle" />
<p>Альбомов пока нет. Создайте первый кнопкой <span class="text-text">+</span> в панели альбомов.</p>
</div>
{:else}
{#if albums.list.length === 0}
<div class="text-text-muted mb-4 flex flex-col items-center gap-3 py-10">
<Icon name="photo_library" class="size-16 text-text-subtle" />
<p>Альбомов пока нет. Создайте первый кнопкой <span class="text-text">+</span> в панели альбомов.</p>
</div>
{/if}
<ul
class="grid gap-3"
style="grid-template-columns: repeat({density.cols}, minmax(0, 1fr));"
@@ -131,6 +131,45 @@
}
}
function selectPhoto(i: number, e: MouseEvent) {
const photo = photos[i];
if (!photo) return;
const cur = selection.albumId === albumId ? selection.items : [];
if (e.shiftKey && selection.anchorId && selection.albumId === albumId) {
const a = photos.findIndex((p) => p.id === selection.anchorId);
if (a >= 0) {
const lo = Math.min(a, i);
const hi = Math.max(a, i);
selection.set(albumId, photos.slice(lo, hi + 1));
return;
}
}
if (e.ctrlKey || e.metaKey) {
const exists = cur.some((p) => p.id === photo.id);
selection.set(
albumId,
exists ? cur.filter((p) => p.id !== photo.id) : [...cur, photo]
);
selection.anchorId = photo.id;
return;
}
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
function dragPhoto(i: number, e: DragEvent) {
const photo = photos[i];
if (!photo) return;
if (!(selection.albumId === albumId && selection.has(photo.id))) {
selection.set(albumId, [photo]);
selection.anchorId = photo.id;
}
const ids = selection.ids;
e.dataTransfer?.setData('application/x-galo-photos', JSON.stringify(ids));
e.dataTransfer?.setData('text/plain', `${ids.length} фото`);
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copyMove';
}
function onDrop(ev: DragEvent) {
ev.preventDefault();
dragging = false;
@@ -151,6 +190,7 @@
aria-label="Альбом"
ondragover={(e) => {
if (!canUpload) return;
if (!e.dataTransfer?.types.includes('Files')) return;
e.preventDefault();
dragging = true;
}}
@@ -202,11 +242,9 @@
{:else}
<PhotoGrid
{photos}
selectedId={selection.albumId === albumId ? (selection.photo?.id ?? null) : null}
onSelect={(i) => {
const p = photos[i];
if (p) selection.select(albumId, p);
}}
selectedIds={selection.albumId === albumId ? selection.ids : []}
onSelect={selectPhoto}
onDragStart={dragPhoto}
onOpen={(i) => {
const p = photos[i];
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
@@ -0,0 +1,121 @@
<script lang="ts">
import Icon from '$lib/components/Icon.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { photosApi } from '$lib/api';
import type { Photo } from '$lib/types';
let photos = $state<Photo[]>([]);
let loading = $state(true);
let error = $state('');
let pending = $state<Photo | null>(null);
let busy = $state(false);
async function load() {
loading = true;
error = '';
try {
photos = (await photosApi.trash()).photos;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
$effect(() => {
load();
});
async function restore(p: Photo) {
try {
await photosApi.restore(p.id);
photos = photos.filter((x) => x.id !== p.id);
} catch (e) {
error = (e as Error).message;
}
}
async function remove() {
if (!pending) return;
busy = true;
try {
await photosApi.permanentDelete(pending.id);
photos = photos.filter((x) => x.id !== pending?.id);
pending = null;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
</script>
<div class="p-4 sm:p-6">
<div class="mb-4 flex items-center gap-2">
<Icon name="delete" class="size-5 text-text-muted" />
<h1 class="text-xl font-semibold">Корзина</h1>
</div>
{#if error}<p class="text-danger mb-3 text-sm">{error}</p>{/if}
{#if loading}
<p class="text-text-muted">Загрузка…</p>
{:else if photos.length === 0}
<div class="text-text-muted flex flex-col items-center gap-3 py-16">
<Icon name="delete" class="size-16 text-text-subtle" />
<p>Корзина пуста.</p>
</div>
{:else}
<ul class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each photos as photo (photo.id)}
<li class="border-border bg-bg-muted overflow-hidden rounded-lg border">
<div class="bg-bg-muted aspect-square overflow-hidden">
{#if photo.thumb_url}
<img src={photo.thumb_url} alt="" class="h-full w-full object-cover" loading="lazy" />
{:else}
<span class="flex h-full w-full items-center justify-center">
<Icon name="image" class="size-8 text-text-subtle" />
</span>
{/if}
</div>
<div class="space-y-1.5 p-2">
<p class="text-text truncate text-xs" title={photo.original_filename}>
{photo.original_filename}
</p>
<p class="text-text-subtle truncate text-xs">{photo.album_title ?? ''}</p>
<div class="flex gap-1">
<button
type="button"
class="border-border text-text hover:bg-bg-muted inline-flex flex-1 items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs"
onclick={() => restore(photo)}
>
<Icon name="arrow_back" class="size-3.5" />
Вернуть
</button>
<button
type="button"
class="border-border text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md border px-2 py-1"
aria-label="Удалить навсегда"
title="Удалить навсегда"
onclick={() => (pending = photo)}
>
<Icon name="delete_forever" class="size-3.5" />
</button>
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
{#if pending}
<ConfirmDialog
title="Удалить навсегда?"
message="Фото будет удалено без возможности восстановления. Файлы хранилища удаляются, если на них нет ссылок в других альбомах."
confirmLabel="Удалить навсегда"
busy={busy}
onConfirm={remove}
onClose={() => (pending = null)}
/>
{/if}