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,157 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { api } from '$lib/api';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import { transfer, type TransferAction } from '$lib/stores/transfer.svelte';
|
||||
|
||||
let targetId = $state('');
|
||||
let action = $state<TransferAction>('move');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (transfer.open) {
|
||||
targetId = transfer.targetId ?? '';
|
||||
action = transfer.action ?? 'move';
|
||||
error = '';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (transfer.open) albums.load();
|
||||
});
|
||||
|
||||
async function confirm() {
|
||||
if (!targetId || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const body = { ids: transfer.ids, album_id: targetId };
|
||||
if (action === 'move') await api('/photo-batch/move', { method: 'POST', body });
|
||||
else await api('/photo-batch/copy', { method: 'POST', body });
|
||||
selection.clear();
|
||||
transfer.close();
|
||||
albums.load();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape' && !busy) transfer.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
{#if transfer.open}
|
||||
<div
|
||||
use:portal
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Перенос или копирование"
|
||||
tabindex="-1"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget && !busy) transfer.close();
|
||||
}}
|
||||
onkeydown={onKey}
|
||||
>
|
||||
<div class="border-border bg-bg-elevated flex max-h-[85vh] w-full max-w-md flex-col rounded-xl border">
|
||||
<div class="border-border flex items-center justify-between border-b px-5 py-3">
|
||||
<h2 class="text-text text-base font-semibold">
|
||||
{action === 'move' ? 'Переместить' : 'Копировать'} {transfer.ids.length} фото
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
|
||||
aria-label="Закрыть"
|
||||
onclick={() => transfer.close()}
|
||||
>
|
||||
<Icon name="close" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
|
||||
<div class="border-border inline-flex rounded-lg border p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm {action === 'move'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-text-muted hover:text-text'}"
|
||||
onclick={() => (action = 'move')}
|
||||
>
|
||||
Переместить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm {action === 'copy'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'text-text-muted hover:text-text'}"
|
||||
onclick={() => (action = 'copy')}
|
||||
>
|
||||
Копировать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-text-muted mb-1.5 text-xs">Альбом</p>
|
||||
{#if albums.list.length === 0}
|
||||
<p class="text-text-subtle text-sm">Нет доступных альбомов.</p>
|
||||
{:else}
|
||||
<ul class="max-h-56 space-y-1 overflow-y-auto">
|
||||
{#each albums.list as album (album.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 rounded-md border px-2 py-1.5 text-left text-sm {targetId ===
|
||||
album.id
|
||||
? 'border-primary bg-bg-muted text-text'
|
||||
: 'border-border text-text-muted hover:bg-bg-muted hover:text-text'}"
|
||||
aria-pressed={targetId === album.id}
|
||||
onclick={() => (targetId = album.id)}
|
||||
>
|
||||
<span
|
||||
class="bg-bg-muted border-border flex size-8 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" />
|
||||
{:else}
|
||||
<Icon name="image" class="size-4 text-text-subtle" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="truncate">{album.title}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error}<p class="text-danger text-sm">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-border flex justify-end gap-2 border-t px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
class="border-border text-text hover:bg-bg-muted rounded-lg border px-4 py-2 text-sm"
|
||||
onclick={() => transfer.close()}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !targetId}
|
||||
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
onclick={confirm}
|
||||
>
|
||||
{busy ? 'Обработка…' : action === 'move' ? 'Переместить' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user