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,19 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { page } from '$app/stores';
|
||||
import Icon from './Icon.svelte';
|
||||
import { contextMenu, type ContextMenuItem } from '$lib/stores/context-menu.svelte';
|
||||
import { transfer } from '$lib/stores/transfer.svelte';
|
||||
import { viewerActions } from '$lib/stores/viewer-actions.svelte';
|
||||
import type { Photo } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
photos: Photo[];
|
||||
index: number;
|
||||
canDelete?: boolean;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onDelete?: (photo: Photo) => void;
|
||||
}
|
||||
|
||||
let { photos, index, canDelete = false, onClose, onIndexChange, onDelete }: Props = $props();
|
||||
let { photos, index, onClose, onIndexChange }: Props = $props();
|
||||
|
||||
let fresh = $state<Photo | null>(null);
|
||||
let touchX = 0;
|
||||
@@ -98,9 +100,7 @@
|
||||
});
|
||||
|
||||
const topClass = $derived(
|
||||
isFullscreen
|
||||
? `border-border bg-chrome absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 border-b px-2 py-0.5 transition-transform duration-200 ${showTop ? 'translate-y-0' : '-translate-y-full'}`
|
||||
: 'border-border bg-chrome flex shrink-0 items-center justify-between gap-1 border-b px-2 py-0.5'
|
||||
`pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center justify-between gap-1 bg-transparent px-2 py-0.5 transition-transform duration-200 ${isFullscreen && !showTop ? '-translate-y-full' : 'translate-y-0'}`
|
||||
);
|
||||
const bottomClass = $derived(
|
||||
isFullscreen
|
||||
@@ -288,22 +288,56 @@
|
||||
else if (dx < -50) next();
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const p = current;
|
||||
if (!p) return;
|
||||
const items: ContextMenuItem[] = [
|
||||
...(p.download_url
|
||||
? [
|
||||
{
|
||||
label: 'Скачать',
|
||||
icon: 'download' as const,
|
||||
run: () => window.open(p.download_url, '_blank')
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Переместить',
|
||||
icon: 'arrow_back',
|
||||
run: () => transfer.start([p.id], null, 'move')
|
||||
},
|
||||
{
|
||||
label: 'Копировать',
|
||||
icon: 'content_copy',
|
||||
run: () => transfer.start([p.id], null, 'copy')
|
||||
},
|
||||
{
|
||||
label: 'Удалить',
|
||||
icon: 'delete',
|
||||
danger: true,
|
||||
run: () => viewerActions.requestDelete(p.id)
|
||||
}
|
||||
];
|
||||
contextMenu.show(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
const btn =
|
||||
'text-text-muted hover:bg-bg-muted hover:text-text inline-flex items-center justify-center rounded-md p-1';
|
||||
const dangerBtn = 'text-danger hover:bg-bg-muted inline-flex items-center justify-center rounded-md p-1';
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} onmousemove={onWinMouseMove} />
|
||||
|
||||
<div bind:this={viewerEl} class="bg-canvas relative flex h-full flex-col overflow-hidden">
|
||||
<div bind:this={topEl} class={topClass}>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button type="button" class={btn} aria-label="Назад" title="Назад" onclick={onClose}>
|
||||
<Icon name="arrow_back" class="size-4" />
|
||||
</button>
|
||||
<span class="text-text-muted text-xs">{index + 1} / {photos.length}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="pointer-events-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class={btn}
|
||||
@@ -316,29 +350,6 @@
|
||||
>
|
||||
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
|
||||
</button>
|
||||
{#if fresh?.download_url}
|
||||
<a
|
||||
href={fresh.download_url}
|
||||
class={btn}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Скачать"
|
||||
title="Скачать"
|
||||
>
|
||||
<Icon name="download" class="size-4" />
|
||||
</a>
|
||||
{/if}
|
||||
{#if canDelete}
|
||||
<button
|
||||
type="button"
|
||||
class={dangerBtn}
|
||||
aria-label="Удалить фото"
|
||||
title="Удалить фото"
|
||||
onclick={() => current && onDelete?.(current)}
|
||||
>
|
||||
<Icon name="delete" class="size-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -365,6 +376,7 @@
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
oncontextmenu={onContextMenu}
|
||||
ontouchstart={onTouchStart}
|
||||
ontouchend={onTouchEnd}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user