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:
@@ -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}
|
||||
Reference in New Issue
Block a user