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:
@@ -4,6 +4,7 @@
|
||||
import AlbumRail from '$lib/components/AlbumRail.svelte';
|
||||
import AlbumPanel from '$lib/components/AlbumPanel.svelte';
|
||||
import TransferDialog from '$lib/components/TransferDialog.svelte';
|
||||
import AlbumActionsDialogs from '$lib/components/AlbumActionsDialogs.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -21,3 +22,4 @@
|
||||
</AppShell>
|
||||
|
||||
<TransferDialog />
|
||||
<AlbumActionsDialogs />
|
||||
|
||||
@@ -4,10 +4,23 @@
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import { density } from '$lib/stores/density.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { albumActions } from '$lib/stores/album-actions.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import type { Album } from '$lib/types';
|
||||
|
||||
$effect(() => {
|
||||
if (auth.isAuthenticated && !albums.loaded) albums.load();
|
||||
});
|
||||
|
||||
function onAlbumContextMenu(ev: MouseEvent, album: Album) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: 'Переименовать', icon: 'edit', run: () => albumActions.openRename(album) },
|
||||
{ label: 'Удалить', icon: 'delete', danger: true, run: () => albumActions.openDelete(album) }
|
||||
];
|
||||
contextMenu.show(ev.clientX, ev.clientY, items);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !auth.isAuthenticated}
|
||||
@@ -44,7 +57,11 @@
|
||||
>
|
||||
{#each albums.list as album (album.id)}
|
||||
<li>
|
||||
<a href="/albums/{album.id}" class="group block">
|
||||
<a
|
||||
href="/albums/{album.id}"
|
||||
class="group block"
|
||||
oncontextmenu={(e) => onAlbumContextMenu(e, album)}
|
||||
>
|
||||
<div
|
||||
class="bg-bg-muted border-border aspect-square overflow-hidden rounded-lg border"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -4,25 +4,19 @@
|
||||
import { api } from '$lib/api';
|
||||
import Viewer from '$lib/components/Viewer.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import type { Album, Photo } from '$lib/types';
|
||||
|
||||
const albumId = $derived($page.params.id ?? '');
|
||||
const photoId = $derived($page.params.photoId ?? '');
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
let role = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let pendingDelete = $state<Photo | null>(null);
|
||||
let deleting = $state(false);
|
||||
|
||||
const canEdit = $derived(role === 'owner' || role === 'admin');
|
||||
const index = $derived(photos.findIndex((p) => p.id === photoId));
|
||||
const currentPhoto = $derived(index >= 0 ? photos[index] : null);
|
||||
const canDelete = $derived(
|
||||
canEdit || (!!currentPhoto && currentPhoto.uploader_id === auth.user?.id)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const id = albumId;
|
||||
@@ -32,9 +26,8 @@
|
||||
api<{ photos: Photo[] }>(`/albums/${id}/photos?limit=100`),
|
||||
api<{ album: Album; role: string }>(`/albums/${id}`)
|
||||
])
|
||||
.then(([p, a]) => {
|
||||
.then(([p]) => {
|
||||
photos = p.photos;
|
||||
role = a.role;
|
||||
})
|
||||
.catch((e) => (error = (e as Error).message))
|
||||
.finally(() => (loading = false));
|
||||
@@ -49,9 +42,13 @@
|
||||
if (p) goto(`/albums/${albumId}/photos/${p.id}`, { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
async function removePhoto(photo: Photo) {
|
||||
pendingDelete = photo;
|
||||
}
|
||||
$effect(() => {
|
||||
const id = viewerActions.deleteRequested;
|
||||
if (!id) return;
|
||||
const photo = photos.find((p) => p.id === id);
|
||||
if (photo) pendingDelete = photo;
|
||||
viewerActions.clear();
|
||||
});
|
||||
|
||||
async function confirmDelete() {
|
||||
const photo = pendingDelete;
|
||||
@@ -92,14 +89,7 @@
|
||||
{:else}
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="min-h-0 flex-1">
|
||||
<Viewer
|
||||
{photos}
|
||||
{index}
|
||||
{canDelete}
|
||||
onClose={close}
|
||||
onIndexChange={change}
|
||||
onDelete={removePhoto}
|
||||
/>
|
||||
<Viewer {photos} {index} onClose={close} onIndexChange={change} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/Icon.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { photosApi } from '$lib/api';
|
||||
import { portal } from '$lib/actions/portal';
|
||||
import { photosApi, ApiError } from '$lib/api';
|
||||
import { albums } from '$lib/stores/albums.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
let photos = $state<Photo[]>([]);
|
||||
@@ -10,6 +12,13 @@
|
||||
let pending = $state<Photo | null>(null);
|
||||
let busy = $state(false);
|
||||
|
||||
let restoring = $state<Photo | null>(null);
|
||||
let targetId = $state('');
|
||||
let createNew = $state(false);
|
||||
let newTitle = $state('');
|
||||
let restoreBusy = $state(false);
|
||||
let restoreError = $state('');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = '';
|
||||
@@ -26,12 +35,47 @@
|
||||
load();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (restoring && !albums.loaded) albums.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;
|
||||
if (e instanceof ApiError && e.code === 'album_required') {
|
||||
restoring = p;
|
||||
targetId = '';
|
||||
createNew = false;
|
||||
newTitle = '';
|
||||
restoreError = '';
|
||||
} else {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
const photo = restoring;
|
||||
if (!photo) return;
|
||||
restoreBusy = true;
|
||||
restoreError = '';
|
||||
try {
|
||||
let id = targetId;
|
||||
if (createNew) {
|
||||
if (!newTitle.trim()) return;
|
||||
const album = await albums.create(newTitle.trim());
|
||||
id = album.id;
|
||||
}
|
||||
if (!id) return;
|
||||
await photosApi.restore(photo.id, id);
|
||||
photos = photos.filter((x) => x.id !== photo.id);
|
||||
restoring = null;
|
||||
} catch (e) {
|
||||
restoreError = (e as Error).message;
|
||||
} finally {
|
||||
restoreBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +126,9 @@
|
||||
<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>
|
||||
<p class="text-text-subtle truncate text-xs">
|
||||
{photo.album_title || 'Без альбома'}
|
||||
</p>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
@@ -119,3 +165,103 @@
|
||||
onClose={() => (pending = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if restoring}
|
||||
<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 && !restoreBusy) restoring = null;
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape' && !restoreBusy) restoring = null;
|
||||
}}
|
||||
>
|
||||
<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">Восстановить фото</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="text-text-muted hover:text-text inline-flex size-7 items-center justify-center rounded"
|
||||
aria-label="Закрыть"
|
||||
onclick={() => (restoring = null)}
|
||||
>
|
||||
<Icon name="close" class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto px-5 py-4">
|
||||
<p class="text-text-muted text-xs">
|
||||
Исходный альбом удалён. Выберите альбом или создайте новый.
|
||||
</p>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" class="accent-primary size-4" bind:checked={createNew} />
|
||||
Создать новый альбом
|
||||
</label>
|
||||
|
||||
{#if createNew}
|
||||
<input
|
||||
bind:value={newTitle}
|
||||
placeholder="Название альбома"
|
||||
maxlength="120"
|
||||
class="border-border bg-bg text-text w-full rounded-lg border px-3 py-2 text-sm"
|
||||
/>
|
||||
{:else 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}
|
||||
|
||||
{#if restoreError}<p class="text-danger text-sm">{restoreError}</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={() => (restoring = null)}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={restoreBusy || (createNew ? !newTitle.trim() : !targetId)}
|
||||
class="bg-primary text-primary-fg rounded-lg px-4 py-2 text-sm disabled:opacity-50"
|
||||
onclick={confirmRestore}
|
||||
>
|
||||
{restoreBusy ? 'Восстановление…' : 'Восстановить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import IconSprite from '$lib/components/IconSprite.svelte';
|
||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||
import { auth } from '$lib/stores/auth.svelte';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { density } from '$lib/stores/density.svelte';
|
||||
@@ -23,3 +24,4 @@
|
||||
|
||||
<IconSprite />
|
||||
{@render children()}
|
||||
<ContextMenu />
|
||||
|
||||
Reference in New Issue
Block a user