- 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)
434 lines
12 KiB
Svelte
434 lines
12 KiB
Svelte
<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>
|