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
+78 -4
View File
@@ -1,14 +1,17 @@
<script lang="ts">
import { page } from '$app/stores';
import { api } from '$lib/api';
import { goto } from '$app/navigation';
import { api, photosApi } from '$lib/api';
import Icon from './Icon.svelte';
import CoverPicker from './CoverPicker.svelte';
import Comments from './Comments.svelte';
import ConfirmDialog from './ConfirmDialog.svelte';
import PanelSection from './PanelSection.svelte';
import PhotoInfoSettingsDialog from './PhotoInfoSettingsDialog.svelte';
import { albums } from '$lib/stores/albums.svelte';
import { auth } from '$lib/stores/auth.svelte';
import { selection } from '$lib/stores/selection.svelte';
import { transfer } from '$lib/stores/transfer.svelte';
import { buildExifRows, EXIF_BASE, EXIF_CATALOG, EXIF_LABELS } from '$lib/exif';
import type { Album, Photo, Preferences } from '$lib/types';
@@ -26,7 +29,8 @@
let savingName = $state(false);
let nameError = $state('');
const selectedPhoto = $derived(selection.albumId === albumId ? selection.photo : null);
const selectedItems = $derived(selection.albumId === albumId ? selection.items : []);
const selectedPhoto = $derived(selectedItems.length === 1 ? selectedItems[0] : null);
const panelPhoto = $derived(photoId ? photo : selectedPhoto);
const canEdit = $derived(role === 'owner' || role === 'admin');
@@ -54,6 +58,26 @@
let settingsOpen = $state(false);
let showAll = $state(false);
let savingPrefs = $state(false);
let pendingDeleteSelected = $state(false);
let deletingSelected = $state(false);
async function deleteSelected() {
deletingSelected = true;
try {
await photosApi.deleteBatch(selection.ids);
selection.clear();
pendingDeleteSelected = false;
} catch (e) {
error = (e as Error).message;
} finally {
deletingSelected = false;
}
}
function openSelected() {
const first = selectedItems[0];
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
}
async function savePreferences(next: Preferences) {
savingPrefs = true;
@@ -105,7 +129,7 @@
$effect(() => {
// сброс временного состояния панели при выборе другого фото в сетке
selectedPhoto?.id;
selection.ids.join(',');
editingName = false;
nameError = '';
showAll = false;
@@ -129,7 +153,7 @@
body: { original_filename: nameDraft.trim() }
});
photo = data.photo;
if (selectedPhoto?.id === data.photo.id) selection.select(albumId, data.photo);
if (selectedItems.some((p) => p.id === data.photo.id)) selection.replace(data.photo);
editingName = false;
} catch (e) {
nameError = (e as Error).message;
@@ -169,8 +193,45 @@
const metaRow = 'text-text-muted flex justify-between gap-2 py-0.5 text-xs';
</script>
{#snippet selectionActions()}
<div class="border-border border-b p-4">
<p class="text-text-subtle mb-1 text-xs font-semibold tracking-wide uppercase">Действия</p>
<nav class="space-y-0.5">
<button type="button" class={navLink} onclick={openSelected}>
<Icon name="image" class="size-4" />
Открыть (просмотр)
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'move')}
>
<Icon name="arrow_back" class="size-4" />
Переместить
</button>
<button
type="button"
class={navLink}
onclick={() => transfer.start(selection.ids, null, 'copy')}
>
<Icon name="content_copy" class="size-4" />
Копировать
</button>
<button
type="button"
class="text-danger hover:bg-bg-muted flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
onclick={() => (pendingDeleteSelected = true)}
>
<Icon name="delete" class="size-4" />
Удалить
</button>
</nav>
</div>
{/snippet}
{#if panelPhoto}
{@const p = panelPhoto}
{#if !photoId}{@render selectionActions()}{/if}
<div>
<div class="p-4">
{#if editingName}
@@ -293,6 +354,8 @@
onClose={() => (settingsOpen = false)}
/>
{/if}
{:else if !photoId && selectedItems.length > 1}
{@render selectionActions()}
{:else if album && !photoId}
<div>
<PanelSection id="album-cover" title="Обложка">
@@ -365,3 +428,14 @@
/>
{/if}
{/if}
{#if pendingDeleteSelected}
<ConfirmDialog
title="Удалить выбранные фото?"
message="{selectedItems.length} фото будут перемещены в корзину."
confirmLabel="Удалить"
busy={deletingSelected}
onConfirm={deleteSelected}
onClose={() => (pendingDeleteSelected = false)}
/>
{/if}