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