feat: context menus, unified photo panel, tags, album trash and counters
- backend: deleting an album moves its photos to the trash without losing files (photos.album_id nullable, FK ON DELETE SET NULL, migration 000006; dropped S3 prefix delete); restore accepts an album target (album_required when the album is gone); user-scoped tags (000007) with GET /tags, POST/DELETE /photos/:id/tags, tags included in photo responses; GET /trash/count - frontend: right-click context menus for photos (grid + viewer) and albums (rail + overview); single shared PhotoPanel reused in viewer and gallery selection (not a copy); viewer download/delete moved to the panel 'Действия'; transparent compact viewer top bar; trash entry as last rail item with per-album photo counts and trash count; album rename/delete dialogs - openspec: sync lightroom-workspace-ui artifacts (specs photos/ui-workspace, design #24-30, tasks 9.17-9.24)
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api';
|
||||
import { api, photosApi } from '$lib/api';
|
||||
import PhotoGrid from '$lib/components/PhotoGrid.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import Uploader from '$lib/components/Uploader.svelte';
|
||||
import DensityControl from '$lib/components/DensityControl.svelte';
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { selection } from '$lib/stores/selection.svelte';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { SSEClient, type SSEEvent } from '$lib/sse';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
@@ -21,6 +24,8 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let dragging = $state(false);
|
||||
let pendingDelete = $state(false);
|
||||
let deletingMenu = $state(false);
|
||||
let uploader = $state<{ pick: () => void; upload: (files: FileList | File[]) => void }>();
|
||||
|
||||
let canUpload = $derived(['owner', 'admin', 'editor', 'member'].includes(role));
|
||||
@@ -86,10 +91,12 @@
|
||||
} else {
|
||||
photos = [photo, ...photos];
|
||||
}
|
||||
loadAlbum().catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'photo.deleted': {
|
||||
photos = photos.filter((p) => p.id !== data.id);
|
||||
loadAlbum().catch(() => {});
|
||||
break;
|
||||
}
|
||||
case 'photo.updated': {
|
||||
@@ -177,6 +184,80 @@
|
||||
uploader?.upload(ev.dataTransfer.files);
|
||||
}
|
||||
}
|
||||
|
||||
function openMenuForPhoto(i: number, e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const photo = photos[i];
|
||||
if (!photo) return;
|
||||
const inSel = selection.albumId === albumId && selection.has(photo.id);
|
||||
const multi = inSel && selection.count > 1;
|
||||
if (!inSel) {
|
||||
selection.set(albumId, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
}
|
||||
const openFirst = () => {
|
||||
const first = selection.albumId === albumId ? selection.items[0] : photo;
|
||||
if (first) goto(`/albums/${albumId}/photos/${first.id}`);
|
||||
};
|
||||
let items: ContextMenuItem[];
|
||||
if (multi) {
|
||||
const ids = selection.ids;
|
||||
items = [
|
||||
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||
{ label: 'Переместить', icon: 'arrow_back', run: () => transfer.start(ids, null, 'move') },
|
||||
{ label: 'Копировать', icon: 'content_copy', run: () => transfer.start(ids, null, 'copy') },
|
||||
{ label: 'Удалить', icon: 'delete', danger: true, run: () => (pendingDelete = true) }
|
||||
];
|
||||
} else {
|
||||
items = [
|
||||
{ label: 'Открыть (просмотр)', icon: 'image', run: openFirst },
|
||||
...(photo.download_url
|
||||
? [
|
||||
{
|
||||
label: 'Скачать',
|
||||
icon: 'download' as const,
|
||||
run: () => window.open(photo.download_url, '_blank')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Переместить',
|
||||
icon: 'arrow_back',
|
||||
run: () => transfer.start([photo.id], null, 'move')
|
||||
},
|
||||
{
|
||||
label: 'Копировать',
|
||||
icon: 'content_copy',
|
||||
run: () => transfer.start([photo.id], null, 'copy')
|
||||
},
|
||||
{
|
||||
label: 'Удалить',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
run: () => {
|
||||
selection.set(albumId, [photo]);
|
||||
selection.anchorId = photo.id;
|
||||
pendingDelete = true;
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
contextMenu.show(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
async function confirmMenuDelete() {
|
||||
deletingMenu = true;
|
||||
try {
|
||||
await photosApi.deleteBatch(selection.ids);
|
||||
selection.clear();
|
||||
pendingDelete = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
deletingMenu = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -245,6 +326,7 @@
|
||||
selectedIds={selection.albumId === albumId ? selection.ids : []}
|
||||
onSelect={selectPhoto}
|
||||
onDragStart={dragPhoto}
|
||||
onContextMenu={openMenuForPhoto}
|
||||
onOpen={(i) => {
|
||||
const p = photos[i];
|
||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`);
|
||||
@@ -284,7 +366,19 @@
|
||||
for (const p of created) {
|
||||
if (!photos.some((x) => x.id === p.id)) photos = [p, ...photos];
|
||||
}
|
||||
loadAlbum().catch(() => {});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingDelete}
|
||||
<ConfirmDialog
|
||||
title="Удалить выбранные фото?"
|
||||
message="{selection.count} фото будут перемещены в корзину."
|
||||
confirmLabel="Удалить"
|
||||
busy={deletingMenu}
|
||||
onConfirm={confirmMenuDelete}
|
||||
onClose={() => (pendingDelete = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user