Files
ds-galo/frontend/src/lib/components/Viewer.svelte
T
iu 61b45b2742 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)
2026-10-10 22:45:41 +03:00

434 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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;
onClose: () => void;
onIndexChange: (index: number) => void;
}
let { photos, index, onClose, onIndexChange }: Props = $props();
let fresh = $state<Photo | null>(null);
let touchX = 0;
const current = $derived(photos[index]);
const MAX_ZOOM = 2;
let viewerEl: HTMLDivElement | undefined = $state();
let scroller: HTMLDivElement | undefined = $state();
let imgEl: HTMLImageElement | undefined = $state();
let topEl: HTMLDivElement | undefined = $state();
let bottomEl: HTMLDivElement | undefined = $state();
let isFullscreen = $state(false);
let zoomed = $state(false);
let scale = $state(1);
let hiResLoaded = $state(false);
let showTop = $state(false);
let showBottom = $state(false);
let showLeft = $state(false);
let showRight = $state(false);
const EDGE = 8;
let dragging = false;
let startX = 0;
let startY = 0;
let startL = 0;
let startT = 0;
const previewSrc = $derived(
fresh?.preview_url ?? fresh?.original_url ?? current?.preview_url ?? current?.original_url ?? ''
);
const fullSrc = $derived(fresh?.original_url ?? current?.original_url ?? '');
const displaySrc = $derived(zoomed && hiResLoaded && fullSrc ? fullSrc : previewSrc);
$effect(() => {
const id = current?.id;
fresh = null;
zoomed = false;
scale = 1;
hiResLoaded = false;
if (!id) return;
api<{ photo: Photo }>(`/photos/${id}`)
.then((data) => (fresh = data.photo))
.catch(() => {});
});
// In zoom mode, preload the full-resolution original and swap it in once loaded.
$effect(() => {
if (!zoomed || hiResLoaded) return;
const url = fullSrc;
if (!url) return;
const im = new Image();
let alive = true;
im.onload = () => {
if (alive) hiResLoaded = true;
};
im.src = url;
return () => {
alive = false;
im.onload = null;
};
});
$effect(() => {
const el = scroller;
if (!el) return;
const handler = (e: WheelEvent) => onWheel(e);
el.addEventListener('wheel', handler, { passive: false });
return () => el.removeEventListener('wheel', handler);
});
$effect(() => {
const onFs = () => {
isFullscreen = !!viewerEl && document.fullscreenElement === viewerEl;
resetBars();
};
document.addEventListener('fullscreenchange', onFs);
return () => document.removeEventListener('fullscreenchange', onFs);
});
const topClass = $derived(
`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
? `border-border bg-chrome absolute inset-x-0 bottom-0 z-30 flex gap-2 overflow-x-auto border-t p-2 transition-transform duration-200 ${showBottom ? 'translate-y-0' : 'translate-y-full'}`
: 'border-border bg-chrome flex shrink-0 gap-2 overflow-x-auto border-t p-2'
);
const arrowPos =
'absolute top-1/2 z-20 inline-flex -translate-y-1/2 items-center justify-center rounded-full bg-black/40 p-1.5 text-text backdrop-blur transition-opacity duration-200 hover:text-white';
const arrowVisibility = (shown: boolean, disabled: boolean, isZoomed: boolean) =>
isZoomed || !shown ? 'pointer-events-none opacity-0' : disabled ? 'opacity-40' : 'opacity-100';
const leftDisabled = $derived(index === 0 || zoomed);
const rightDisabled = $derived(index === photos.length - 1 || zoomed);
const leftBtnClass = $derived(
`${arrowPos} left-2 ${arrowVisibility(showLeft, leftDisabled, zoomed)}`
);
const rightBtnClass = $derived(
`${arrowPos} right-2 ${arrowVisibility(showRight, rightDisabled, zoomed)}`
);
function resetBars() {
showTop = false;
showBottom = false;
showLeft = false;
showRight = false;
}
function inRect(el: HTMLElement | undefined, x: number, y: number): boolean {
if (!el) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function onWinMouseMove(e: MouseEvent) {
const r = viewerEl?.getBoundingClientRect();
if (r) {
const zoneX = r.width / 6;
const insideY = e.clientY >= r.top && e.clientY <= r.bottom;
showLeft = !zoomed && insideY && e.clientX >= r.left && e.clientX <= r.left + zoneX;
showRight = !zoomed && insideY && e.clientX <= r.right && e.clientX >= r.right - zoneX;
}
if (!isFullscreen) return;
const nearTop = e.clientY <= EDGE;
const nearBottom = e.clientY >= window.innerHeight - EDGE;
showTop = nearTop || inRect(topEl, e.clientX, e.clientY);
showBottom = nearBottom || inRect(bottomEl, e.clientX, e.clientY);
}
function baseWidth(): number {
return fresh?.width || current?.width || imgEl?.naturalWidth || 0;
}
function baseHeight(): number {
return fresh?.height || current?.height || imgEl?.naturalHeight || 0;
}
function prev() {
if (index > 0) onIndexChange(index - 1);
}
function next() {
if (index < photos.length - 1) onIndexChange(index + 1);
}
function toggleActual() {
if (zoomed) {
zoomed = false;
scale = 1;
if (scroller) {
scroller.scrollLeft = 0;
scroller.scrollTop = 0;
}
} else {
zoomed = true;
scale = 1;
tick().then(() => {
if (!scroller) return;
scroller.scrollLeft = (scroller.scrollWidth - scroller.clientWidth) / 2;
scroller.scrollTop = (scroller.scrollHeight - scroller.clientHeight) / 2;
});
}
}
function onWheel(ev: WheelEvent) {
if (!current || !scroller || !imgEl) return;
ev.preventDefault();
ev.stopPropagation();
const natW = baseWidth();
const natH = baseHeight();
if (!natW || !natH) return;
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
const fitScale = Math.min(scroller.clientWidth / natW, scroller.clientHeight / natH, 1);
const curScale = zoomed ? scale : fitScale;
const newScale = Math.min(MAX_ZOOM, Math.max(fitScale, curScale * factor));
if (Math.abs(newScale - curScale) < 1e-4) return;
const r = imgEl.getBoundingClientRect();
const fx = (ev.clientX - r.left) / r.width;
const fy = (ev.clientY - r.top) / r.height;
zoomed = true;
scale = newScale;
tick().then(() => {
if (!scroller) return;
const sr = scroller.getBoundingClientRect();
scroller.scrollLeft = fx * natW * newScale - (ev.clientX - sr.left);
scroller.scrollTop = fy * natH * newScale - (ev.clientY - sr.top);
});
}
function onPointerDown(ev: PointerEvent) {
if (!zoomed || !scroller || ev.pointerType !== 'mouse') return;
dragging = true;
startX = ev.clientX;
startY = ev.clientY;
startL = scroller.scrollLeft;
startT = scroller.scrollTop;
try {
scroller.setPointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
function onPointerMove(ev: PointerEvent) {
if (!dragging || !scroller) return;
ev.preventDefault();
scroller.scrollLeft = startL - (ev.clientX - startX);
scroller.scrollTop = startT - (ev.clientY - startY);
}
function onPointerUp(ev: PointerEvent) {
if (!dragging) return;
dragging = false;
try {
scroller?.releasePointerCapture(ev.pointerId);
} catch {
/* ignore */
}
}
async function toggleViewerFullscreen() {
if (!viewerEl) return;
try {
if (document.fullscreenElement === viewerEl) await document.exitFullscreen();
else await viewerEl.requestFullscreen();
} catch {
/* недоступно */
}
}
function isTyping(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
}
function onKey(ev: KeyboardEvent) {
if (isTyping()) return;
if (ev.key === 'Escape') {
if (!document.fullscreenElement) onClose();
return;
}
if (ev.code === 'Space') {
ev.preventDefault();
toggleActual();
return;
}
if (ev.key === 'f' || ev.key === 'F' || ev.key === 'а' || ev.key === 'А') {
ev.preventDefault();
toggleViewerFullscreen();
return;
}
if (zoomed) return;
if (ev.key === 'ArrowLeft') prev();
else if (ev.key === 'ArrowRight') next();
}
function onTouchStart(ev: TouchEvent) {
touchX = ev.changedTouches[0].clientX;
}
function onTouchEnd(ev: TouchEvent) {
if (zoomed) return;
const dx = ev.changedTouches[0].clientX - touchX;
if (dx > 50) prev();
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';
</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="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="pointer-events-auto flex items-center gap-1">
<button
type="button"
class={btn}
aria-label={isFullscreen ? 'Выйти из полноэкранного просмотра' : 'Полноэкранный просмотр'}
title={isFullscreen
? 'Выйти из полноэкранного просмотра (f)'
: 'Полноэкранный просмотр (f)'}
aria-pressed={isFullscreen}
onclick={toggleViewerFullscreen}
>
<Icon name={isFullscreen ? 'fullscreen_exit' : 'fullscreen'} class="size-4" />
</button>
</div>
</div>
<div class="relative flex min-h-0 flex-1 items-stretch">
<button
type="button"
class={leftBtnClass}
aria-label="Предыдущее фото"
title="Предыдущее фото"
onclick={prev}
disabled={leftDisabled}
>
<Icon name="chevron_left" class="size-8" />
</button>
<div
bind:this={scroller}
class="bg-canvas flex min-h-0 flex-1 overscroll-contain {zoomed
? 'cursor-grab overflow-auto'
: 'overflow-hidden'}"
role="group"
aria-label="Просмотр фото"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
oncontextmenu={onContextMenu}
ontouchstart={onTouchStart}
ontouchend={onTouchEnd}
>
{#if current}
<img
bind:this={imgEl}
src={displaySrc}
alt={current.original_filename}
draggable="false"
style={zoomed ? `width: ${baseWidth() * scale}px; height: auto;` : ''}
class="m-auto block {zoomed
? 'max-w-none shrink-0 select-none'
: 'max-h-full max-w-full object-contain'}"
/>
{/if}
</div>
<button
type="button"
class={rightBtnClass}
aria-label="Следующее фото"
title="Следующее фото"
onclick={next}
disabled={rightDisabled}
>
<Icon name="chevron_right" class="size-8" />
</button>
</div>
<div bind:this={bottomEl} class={bottomClass} aria-label="Лента миниатюр">
{#each photos as photo, i (photo.id)}
<button
type="button"
class="h-16 w-16 shrink-0 overflow-hidden rounded border {i === index
? 'border-primary'
: 'border-border'}"
onclick={() => onIndexChange(i)}
aria-label="Фото {i + 1}"
aria-current={i === index ? 'true' : undefined}
>
{#if photo.thumb_url}
<img
src={photo.thumb_url}
alt=""
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<span class="bg-bg-muted block h-full w-full"></span>
{/if}
</button>
{/each}
</div>
</div>