diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 977c024..699d654 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,149 +1,16 @@ -import { useEffect, useCallback, useMemo, useRef, useState } from "react"; -import { motion, AnimatePresence, type Transition } from "framer-motion"; -import { invoke } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; -import { VideoPlayer } from "./VideoPlayer"; -import { mediaSrc } from "../lib/mediaSrc"; -import { Tooltip } from "./Tooltip"; -import { ChevronRightIcon, CloseIcon, StarIcon } from "./icons"; - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatDate(iso: string | null): string { - if (!iso) return "Unknown"; - return new Date(iso).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); -} - -function formatDuration(durationMs: number | null): string { - if (!durationMs || durationMs <= 0) return "Pending / unavailable"; - const totalSeconds = Math.floor(durationMs / 1000); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -function embeddingLabel(status: string, model: string | null): string { - if (status === "ready") { - return model ? `Ready (${model})` : "Ready"; - } - if (status === "failed") { - return "Failed"; - } - if (status === "processing") { - return "Processing"; - } - return "Queued"; -} - -function ratingPill(rating: AiRating): { label: string; className: string } { - switch (rating) { - case "general": - return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" }; - case "sensitive": - return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" }; - case "questionable": - return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" }; - case "explicit": - return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" }; - } -} - -interface DragRect { - startX: number; - startY: number; - endX: number; - endY: number; -} - -/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */ -function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } { - return { - left: Math.min(r.startX, r.endX), - top: Math.min(r.startY, r.endY), - width: Math.abs(r.endX - r.startX), - height: Math.abs(r.endY - r.startY), - }; -} - -/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 0–1 crop coords - * relative to the actual rendered element bounds. */ -function rectToNormalisedCrop( - rect: DragRect, - imgEl: HTMLImageElement, -): { x: number; y: number; w: number; h: number } | null { - const imgBounds = imgEl.getBoundingClientRect(); - if (imgBounds.width === 0 || imgBounds.height === 0) return null; - - // rect coords are already in viewport space (client coords) - const rawX = Math.min(rect.startX, rect.endX); - const rawY = Math.min(rect.startY, rect.endY); - const rawW = Math.abs(rect.endX - rect.startX); - const rawH = Math.abs(rect.endY - rect.startY); - - // Clamp to image bounds - const clampedX = Math.max(rawX, imgBounds.left); - const clampedY = Math.max(rawY, imgBounds.top); - const clampedRight = Math.min(rawX + rawW, imgBounds.right); - const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom); - - const croppedW = clampedRight - clampedX; - const croppedH = clampedBottom - clampedY; - - if (croppedW <= 0 || croppedH <= 0) return null; - - // Normalize by the CSS transform scale — getBoundingClientRect already returns - // the scaled (on-screen) size, so we normalize directly against that. - return { - x: (clampedX - imgBounds.left) / imgBounds.width, - y: (clampedY - imgBounds.top) / imgBounds.height, - w: croppedW / imgBounds.width, - h: croppedH / imgBounds.height, - }; -} - -/** Minimum selection size as a fraction of the viewport container dimension. */ -const MIN_SELECTION_FRACTION = 0.02; - -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 4; - -interface ViewTransform { - zoom: number; - panX: number; - panY: number; -} - -const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; -const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; -const SLIDESHOW_CONTROLS_IDLE_MS = 5000; -const SLIDESHOW_LOAD_MORE_THRESHOLD = 3; - -/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the - * viewport centre — stays under the cursor across a zoom change. */ -function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { - if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; - const ratio = newZoom / view.zoom; - return { - zoom: newZoom, - panX: anchorX - (anchorX - view.panX) * ratio, - panY: anchorY - (anchorY - view.panY) * ratio, - }; -} +import { useCallback, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useGalleryStore } from "../store"; +import { LightboxDetailsPanel } from "./lightbox/LightboxDetailsPanel"; +import { LightboxNavButton } from "./lightbox/LightboxNavButton"; +import { LightboxViewport } from "./lightbox/LightboxViewport"; +import { SlideshowView } from "./lightbox/SlideshowView"; +import { useLightboxMediaDetails } from "./lightbox/useLightboxMediaDetails"; +import { useLightboxNavigation } from "./lightbox/useLightboxNavigation"; +import { useRegionSelection } from "./lightbox/useRegionSelection"; +import { useSlideshow } from "./lightbox/useSlideshow"; +import { ViewTransform } from "./lightbox/types"; +import { IDENTITY_VIEW } from "./lightbox/viewTransform"; export function Lightbox() { const selectedImage = useGalleryStore((state) => state.selectedImage); @@ -170,90 +37,12 @@ export function Lightbox() { const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); const slideshowTransition = useGalleryStore((state) => state.slideshowTransition); - // Tracks the image id that is currently displayed, used to discard async - // tag mutations that resolve after the user has navigated to another image. - const currentImageIdRef = useRef(null); - currentImageIdRef.current = selectedImage?.id ?? null; - - const [view, setView] = useState(IDENTITY_VIEW); - const zoom = view.zoom; - const [isPanning, setIsPanning] = useState(false); - const lastPanPointRef = useRef({ x: 0, y: 0 }); - const [imageTags, setImageTags] = useState([]); - const [imageExif, setImageExif] = useState(null); - const [tagInput, setTagInput] = useState(""); - const [tagAdding, setTagAdding] = useState(false); - const [tagsExpanded, setTagsExpanded] = useState(false); - const [taggingQueued, setTaggingQueued] = useState(false); - - // Region selection state - const [albumMenuOpen, setAlbumMenuOpen] = useState(false); - const [albumAddedTo, setAlbumAddedTo] = useState(null); - const [newAlbumName, setNewAlbumName] = useState(""); - const [albumAdding, setAlbumAdding] = useState(false); - const [regionSelectMode, setRegionSelectMode] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [dragRect, setDragRect] = useState(null); - const [regionSearching, setRegionSearching] = useState(false); - const [slideshowActive, setSlideshowActive] = useState(false); - const [slideshowPaused, setSlideshowPaused] = useState(false); - const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true); - const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false); - const [slideshowMotionStep, setSlideshowMotionStep] = useState(0); - const lightboxRootRef = useRef(null); const imageViewportRef = useRef(null); const imgRef = useRef(null); - const slideshowControlsIdleTimeoutRef = useRef(null); - const slideshowPointerRef = useRef<{ x: number; y: number } | null>(null); - const slideshowRandomSeenRef = useRef>(new Set()); + const [view, setView] = useState(IDENTITY_VIEW); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; - const slideshowImages = useMemo( - () => images.filter((image) => image.media_kind === "image"), - [images], - ); - const slideshowIndex = selectedImage - ? slideshowImages.findIndex((image) => image.id === selectedImage.id) - : -1; - const slideshowPosition = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1); - const slideshowControlsShown = slideshowControlsVisible; - const slideshowMotionDirections = [ - { x: -1, y: 0.45 }, - { x: 1, y: -0.45 }, - { x: -0.7, y: -0.55 }, - { x: 0.7, y: 0.55 }, - ]; - const slideshowMotionDirection = - slideshowMotionDirections[slideshowMotionStep % slideshowMotionDirections.length]; - const slideshowImageInitial = { opacity: 0, filter: "blur(8px)" }; - const slideshowImageAnimate = { opacity: 1, filter: "blur(0px)" }; - const slideshowImageExit = { opacity: 0, filter: "blur(4px)" }; - const slideshowImageTransition: Transition = { duration: 0.72, ease: SLIDESHOW_EASE }; - const slideshowImageContentInitial = - slideshowTransition === "gentle-motion" - ? { - scale: 1.012, - x: -8 * slideshowMotionDirection.x, - y: 8 * slideshowMotionDirection.y, - } - : { scale: 1.012 }; - const slideshowImageContentAnimate = - slideshowTransition === "gentle-motion" - ? { - scale: 1.026, - x: 8 * slideshowMotionDirection.x, - y: -8 * slideshowMotionDirection.y, - } - : { scale: 1 }; - const slideshowImageContentTransition: Transition = - slideshowTransition === "gentle-motion" - ? { - duration: slideshowIntervalSeconds + 0.75, - ease: "linear", - } - : { duration: 0.72, ease: SLIDESHOW_EASE }; - const canStartSlideshow = slideshowImages.length > 0; const canFindSimilar = selectedImage?.embedding_status === "ready"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; const taggerReady = taggerModelStatus?.ready ?? false; @@ -264,412 +53,71 @@ export function Lightbox() { ? "Queue AI tagging for this image" : "AI tagger model not installed"; - const goPrev = useCallback(() => { - if (currentIndex > 0) openImage(images[currentIndex - 1]); - }, [currentIndex, images, openImage]); - - const goNext = useCallback(() => { - if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); - }, [currentIndex, images, openImage]); - - const exitRegionMode = useCallback(() => { - setRegionSelectMode(false); - setIsDragging(false); - setDragRect(null); - }, []); - - const showSlideshowControls = useCallback(() => { - if (slideshowControlsIdleTimeoutRef.current !== null) { - window.clearTimeout(slideshowControlsIdleTimeoutRef.current); - slideshowControlsIdleTimeoutRef.current = null; - } - setSlideshowControlsVisible(true); - if (slideshowActive) { - slideshowControlsIdleTimeoutRef.current = window.setTimeout(() => { - setSlideshowControlsVisible(false); - slideshowControlsIdleTimeoutRef.current = null; - }, SLIDESHOW_CONTROLS_IDLE_MS); - } - }, [slideshowActive]); - - const exitSlideshow = useCallback(() => { - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - if (document.fullscreenElement === lightboxRootRef.current) { - void document.exitFullscreen().catch(() => undefined); - } - }, []); - - const goSlideshow = useCallback( - (direction: -1 | 1, revealControls = true) => { - if (slideshowImages.length === 0) return; - if (slideshowImages.length === 1) { - openImage(slideshowImages[0]); - return; - } - - const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0; - let nextIndex = - (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; - if (slideshowOrder === "random") { - const currentId = slideshowImages[currentSlideshowIndex]?.id; - let candidates = slideshowImages.filter( - (image) => image.id !== currentId && !slideshowRandomSeenRef.current.has(image.id), - ); - if (candidates.length === 0) { - slideshowRandomSeenRef.current = new Set(currentId == null ? [] : [currentId]); - candidates = slideshowImages.filter((image) => image.id !== currentId); - } - const nextImage = candidates[Math.floor(Math.random() * candidates.length)]; - nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id); - slideshowRandomSeenRef.current.add(nextImage.id); - } - setSlideshowMotionStep((step) => step + 1); - openImage(slideshowImages[nextIndex]); - if (revealControls) { - showSlideshowControls(); - } - }, - [openImage, showSlideshowControls, slideshowImages, slideshowIndex, slideshowOrder], - ); - - const handleSlideshowPointerMove = useCallback( - (event: React.PointerEvent) => { - const previous = slideshowPointerRef.current; - const next = { x: event.clientX, y: event.clientY }; - slideshowPointerRef.current = next; - - if (!previous) { - if (!slideshowControlsVisible) { - showSlideshowControls(); - } - return; - } - - if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) { - showSlideshowControls(); - } - }, - [showSlideshowControls, slideshowControlsVisible], - ); - - const startSlideshow = useCallback(() => { - if (!selectedImage || slideshowImages.length === 0) return; - - const nextImage = - selectedImage.media_kind === "image" - ? selectedImage - : images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ?? - slideshowImages[0]; - - if (nextImage.id !== selectedImage.id) { - openImage(nextImage); - } - - slideshowRandomSeenRef.current = new Set([nextImage.id]); - setSlideshowMotionStep(0); - exitRegionMode(); - setView(IDENTITY_VIEW); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - slideshowPointerRef.current = null; - setSlideshowActive(true); - void lightboxRootRef.current?.requestFullscreen().catch(() => undefined); - }, [currentIndex, exitRegionMode, images, openImage, selectedImage, slideshowImages]); - - // Keep the pan within bounds: when the scaled image overflows the viewport - // the image edge may not be dragged past the viewport edge; when it fits, - // the pan snaps back to centre on that axis. - const clampPan = useCallback((v: ViewTransform): ViewTransform => { - const img = imgRef.current; - const vp = imageViewportRef.current; - if (!img || !vp) return v; - const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2); - const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2); - return { - ...v, - panX: Math.min(maxX, Math.max(-maxX, v.panX)), - panY: Math.min(maxY, Math.max(-maxY, v.panY)), - }; - }, []); - - useEffect(() => { - setView(IDENTITY_VIEW); - setImageTags([]); - setImageExif(null); - setTagInput(""); - setTagsExpanded(false); - setTaggingQueued(false); - exitRegionMode(); - setRegionSearching(false); - }, [selectedImage?.id, exitRegionMode]); - - useEffect(() => { - if (!selectedImage) return; - // Capture the ID so a stale response for image A cannot overwrite B's tags - // when the user navigates before the request resolves. - let cancelled = false; - void getImageTags(selectedImage.id) - .then((tags) => { if (!cancelled) setImageTags(tags); }) - .catch(() => { if (!cancelled) setImageTags([]); }); - return () => { cancelled = true; }; - }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); - - // EXIF is read on demand from the file (not stored), so it works on every - // already-indexed image without a reindex. Only meaningful for images. - useEffect(() => { - if (!selectedImage || selectedImage.media_kind !== "image") { - setImageExif(null); - return; - } - let cancelled = false; - void getImageExif(selectedImage.id) - .then((exif) => { if (!cancelled) setImageExif(exif); }) - .catch(() => { if (!cancelled) setImageExif(null); }); - return () => { cancelled = true; }; - }, [selectedImage?.id, selectedImage?.media_kind, getImageExif]); - - useEffect(() => { - if (selectedImage?.media_kind !== "image" || taggerStatusKnown) return; - void loadTaggerModelStatus(); - }, [loadTaggerModelStatus, selectedImage?.media_kind, taggerStatusKnown]); - - // Reset the queued state once the worker finishes so the button is usable again - useEffect(() => { - if (selectedImage?.ai_tagged_at) setTaggingQueued(false); - }, [selectedImage?.ai_tagged_at]); - - useEffect(() => { - if (selectedImage) return; - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - }, [selectedImage]); - - useEffect(() => { - const handleFullscreenChange = () => { - if (!slideshowActive) return; - if (document.fullscreenElement !== lightboxRootRef.current) { - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - } - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); - }, [slideshowActive]); - - useEffect(() => { - showSlideshowControls(); - return () => { - if (slideshowControlsIdleTimeoutRef.current !== null) { - window.clearTimeout(slideshowControlsIdleTimeoutRef.current); - slideshowControlsIdleTimeoutRef.current = null; - } - }; - }, [showSlideshowControls, slideshowActive, slideshowPaused]); - - useEffect(() => { - if (!slideshowActive || slideshowPaused || slideshowImages.length <= 1) return; - const timeout = window.setTimeout(() => { - goSlideshow(1, false); - }, slideshowIntervalSeconds * 1000); - return () => window.clearTimeout(timeout); - }, [goSlideshow, selectedImage?.id, slideshowActive, slideshowImages.length, slideshowIntervalSeconds, slideshowPaused]); - - useEffect(() => { - if ( - !slideshowActive || - slideshowLoadingMore || - loadedCount >= totalImages || - slideshowImages.length === 0 || - slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD - ) { - return; - } - - setSlideshowLoadingMore(true); - void loadMoreImages().finally(() => setSlideshowLoadingMore(false)); - }, [ - loadedCount, - loadMoreImages, - slideshowActive, - slideshowImages.length, - slideshowIndex, - slideshowLoadingMore, - totalImages, - ]); - - useEffect(() => { - const viewport = imageViewportRef.current; - if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return; - - const handleWheel = (event: WheelEvent) => { - if (regionSelectMode) return; // don't zoom during selection - if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; - event.preventDefault(); - const bounds = viewport.getBoundingClientRect(); - const anchorX = event.clientX - (bounds.left + bounds.width / 2); - const anchorY = event.clientY - (bounds.top + bounds.height / 2); - setView((v) => { - const delta = event.deltaY < 0 ? 0.15 : -0.15; - const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta)); - return clampPan(zoomViewAt(v, next, anchorX, anchorY)); - }); - }; - - viewport.addEventListener("wheel", handleWheel, { passive: false }); - return () => viewport.removeEventListener("wheel", handleWheel); - }, [selectedImage, regionSelectMode, clampPan, slideshowActive]); - - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (!selectedImage) return; - if (slideshowActive) { - if (event.key === "Escape") { - event.preventDefault(); - exitSlideshow(); - return; - } - if (event.key === " ") { - event.preventDefault(); - setSlideshowPaused((paused) => !paused); - showSlideshowControls(); - return; - } - if (event.key === "ArrowLeft" && !event.shiftKey) { - event.preventDefault(); - goSlideshow(-1); - return; - } - if (event.key === "ArrowRight" && !event.shiftKey) { - event.preventDefault(); - goSlideshow(1); - return; - } - return; - } - if (event.key === "Escape") { - if (regionSelectMode) { - exitRegionMode(); - } else { - closeImage(); - } - } - if (regionSelectMode) return; // block nav keys during selection - // Shift+arrows are reserved for video seeking (handled by VideoPlayer) - if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); - if (event.key === "ArrowRight" && !event.shiftKey) goNext(); - if (event.key === "+" || event.key === "=") - setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0))); - if (event.key === "-") - setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0))); - }; - - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [ + const region = useRegionSelection({ selectedImage, + slideshowActive: false, + imageViewportRef, + imgRef, + view, + setView, + findSimilarByRegion, + }); + + const slideshow = useSlideshow({ + rootRef: lightboxRootRef, + selectedImage, + images, + currentIndex, + loadedCount, + totalImages, + intervalSeconds: slideshowIntervalSeconds, + order: slideshowOrder, + transition: slideshowTransition, + openImage, + loadMoreImages, + exitRegionMode: region.exitRegionMode, + setView, + }); + + const resetForSelectedImage = useCallback(() => { + setView(IDENTITY_VIEW); + region.exitRegionMode(); + region.setRegionSearching(false); + }, [region.exitRegionMode, region.setRegionSearching]); + + const details = useLightboxMediaDetails({ + selectedImage, + taggerModelStatus, + getImageTags, + getImageExif, + loadTaggerModelStatus, + onSelectedImageReset: resetForSelectedImage, + }); + + const { goPrev, goNext } = useLightboxNavigation({ + selectedImage, + images, + currentIndex, + slideshowActive: slideshow.active, + regionSelectMode: region.regionSelectMode, closeImage, - exitSlideshow, - goPrev, - goNext, - goSlideshow, - regionSelectMode, - exitRegionMode, - clampPan, - showSlideshowControls, - slideshowActive, - ]); + exitRegionMode: region.exitRegionMode, + exitSlideshow: slideshow.exit, + goSlideshow: slideshow.go, + showSlideshowControls: slideshow.showControls, + setSlideshowPaused: slideshow.setPaused, + openImage, + setView, + clampPan: region.clampPan, + }); - // ── Region selection / pan pointer handlers ───────────────────────────────── - const handleRegionPointerDown = useCallback( - (event: React.PointerEvent) => { - if (!regionSelectMode) { - // Drag-to-pan when the image is zoomed in - if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - lastPanPointRef.current = { x: event.clientX, y: event.clientY }; - setIsPanning(true); - } - return; - } - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - setIsDragging(true); - setDragRect({ - startX: event.clientX, - startY: event.clientY, - endX: event.clientX, - endY: event.clientY, - }); - }, - [regionSelectMode, zoom, selectedImage?.media_kind], - ); - - const handleRegionPointerMove = useCallback( - (event: React.PointerEvent) => { - if (isPanning) { - const dx = event.clientX - lastPanPointRef.current.x; - const dy = event.clientY - lastPanPointRef.current.y; - lastPanPointRef.current = { x: event.clientX, y: event.clientY }; - setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy })); - return; - } - if (!isDragging) return; - setDragRect((prev) => - prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, - ); - }, - [isDragging, isPanning, clampPan], - ); - - const handleRegionPointerUp = useCallback( - (event: React.PointerEvent) => { - if (isPanning) { - event.currentTarget.releasePointerCapture(event.pointerId); - setIsPanning(false); - return; - } - if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { - setIsDragging(false); - return; - } - event.currentTarget.releasePointerCapture(event.pointerId); - - const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }; - const crop = rectToNormalisedCrop(finalRect, imgRef.current); - - setIsDragging(false); - setDragRect(null); - - // Ignore tiny accidental clicks - const containerBounds = imageViewportRef.current?.getBoundingClientRect(); - const containerSize = containerBounds - ? Math.min(containerBounds.width, containerBounds.height) - : 500; - const selW = Math.abs(finalRect.endX - finalRect.startX); - const selH = Math.abs(finalRect.endY - finalRect.startY); - if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) { - exitRegionMode(); - return; - } - - exitRegionMode(); - setRegionSearching(true); - - void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id) - .finally(() => setRegionSearching(false)); - }, - [isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode], - ); - - // Build the CSS rect for the selection overlay (viewport-relative) - const selectionOverlay = - isDragging && dragRect ? normaliseRect(dragRect) : null; + const toggleRegionMode = useCallback(() => { + if (region.regionSelectMode) { + region.exitRegionMode(); + } else { + region.setRegionSelectMode(true); + } + }, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]); return ( @@ -678,739 +126,98 @@ export function Lightbox() { ref={lightboxRootRef} key="lightbox" className={`media-dark-surface fixed inset-0 z-50 flex ${ - slideshowActive ? "bg-black" : "bg-black/90 backdrop-blur-sm" + slideshow.active ? "bg-black" : "bg-black/90 backdrop-blur-sm" }`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - onClick={slideshowActive ? showSlideshowControls : regionSelectMode ? undefined : closeImage} + onClick={slideshow.active ? slideshow.showControls : region.regionSelectMode ? undefined : closeImage} > - {slideshowActive ? ( -
{ - event.stopPropagation(); - showSlideshowControls(); - }} - onPointerMove={handleSlideshowPointerMove} - > - - {selectedImage.media_kind === "image" ? ( - - - - ) : ( - - Finding next image… - - )} - - - -
- {slideshowPosition} - / - {slideshowImages.length} - - {selectedImage.filename} -
- - - -
- - -
- - - - - - - - - -
-
- - {slideshowLoadingMore ? ( -
- Loading more… -
- ) : null} -
+ {slideshow.active ? ( + slideshow.setPaused((paused) => !paused)} + /> ) : ( <> - + -
event.stopPropagation()}> -
-
1 && selectedImage.media_kind === "image" - ? "cursor-grab" - : "" - }`} - onPointerDown={handleRegionPointerDown} - onPointerMove={handleRegionPointerMove} - onPointerUp={handleRegionPointerUp} - > - {/* Region selection mode hint */} - {regionSelectMode && ( -
-
- - - - Draw a region to search — Esc to cancel -
-
- )} - - {/* Selection rectangle overlay */} - {selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && ( -
event.stopPropagation()}> +
+ - )} - - - {selectedImage.media_kind === "video" ? ( - - ) : ( - <> - {selectedImage.filename} - - )} - - - - {!regionSelectMode && ( -
-
- - - - {selectedImage.media_kind === "image" ? ( - <> -
- - {Math.round(zoom * 100)}% - - - ) : null} -
-
- )} -
- -
-
-
-

{selectedImage.filename}

-

Details

-
-
- - - - - - -
- -
- - {/* Search region button row */} - {canSearchRegion && ( -
- - - -
- )} - -
-
-
-

Rating

-
- {Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; - return ( - - - - ); - })} - {selectedImage.rating > 0 ? ( - - - - ) : null} -
-
- -
-

Dimensions

-

- {selectedImage.width && selectedImage.height - ? `${selectedImage.width} x ${selectedImage.height}px` - : "Pending / unavailable"} -

-
- - {selectedImage.media_kind === "video" ? ( - <> -
-

Duration

-

{formatDuration(selectedImage.duration_ms)}

-
- -
-

Video codec

-

{selectedImage.video_codec ?? "Pending / unavailable"}

-
- -
-

Audio codec

-

{selectedImage.audio_codec ?? "None / unavailable"}

-
- - {selectedImage.metadata_error ? ( -
-

Metadata

-

{selectedImage.metadata_error}

-
- ) : null} - - ) : null} - -
-

Type

-

{selectedImage.mime_type}

-
- -
-

File size

-

{formatBytes(selectedImage.file_size)}

-
- -
-

Modified

-

{formatDate(selectedImage.modified_at)}

-
- -
-

Embedding

-

{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}

- {selectedImage.embedding_error ? ( -

{selectedImage.embedding_error}

- ) : null} -
-
- -
-
-

Tags

-
- {selectedImage.ai_rating ? ( - - {ratingPill(selectedImage.ai_rating).label} - - ) : null} - {selectedImage.media_kind === "image" ? ( - - - ) : null} -
-
- - {imageTags.length > 0 ? ( - <> -
- {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => ( - - - {t.tag} - - - - - - ))} -
- {imageTags.length > 8 && ( - - )} - - ) : ( -

No tags yet

- )} - -
{ - e.preventDefault(); - const raw = tagInput.trim(); - if (!raw || tagAdding) return; - setTagAdding(true); - const taggedImageId = selectedImage.id; - void addUserTag(taggedImageId, raw) - .then((newTag) => { - // Discard if the user navigated away before the request resolved. - if (currentImageIdRef.current !== taggedImageId) return; - setImageTags((prev) => [...prev, newTag]); - setTagInput(""); - }) - .catch(() => undefined) - .finally(() => setTagAdding(false)); - }} - > - setTagInput(e.target.value)} - disabled={tagAdding} - /> - -
-
- -
-
-

Albums

- -
- {albumMenuOpen ? ( -
-
- {albums.length === 0 ? ( -

No albums yet — create one below.

- ) : ( - albums.map((album) => ( - - )) - )} -
-
{ - e.preventDefault(); - const name = newAlbumName.trim(); - if (!name || albumAdding) return; - setAlbumAdding(true); - void createAlbum(name) - .then(async (album) => { - await addToAlbum(album.id, [selectedImage.id]); - setAlbumAddedTo(album.id); - setNewAlbumName(""); - }) - .catch(() => undefined) - .finally(() => setAlbumAdding(false)); - }} - > - setNewAlbumName(e.target.value)} - disabled={albumAdding} - /> - -
-
- ) : null} -
- - {imageExif && - (imageExif.make || - imageExif.model || - imageExif.lens || - imageExif.f_number || - imageExif.exposure_time || - imageExif.iso || - imageExif.focal_length || - (imageExif.gps_lat != null && imageExif.gps_lon != null)) ? ( -
-

Camera

-
- {imageExif.make || imageExif.model ? ( -

- {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} -

- ) : null} - {imageExif.lens ?

{imageExif.lens}

: null} - {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( -
- {imageExif.f_number ? {imageExif.f_number} : null} - {imageExif.exposure_time ? {imageExif.exposure_time} : null} - {imageExif.iso ? ISO {imageExif.iso} : null} - {imageExif.focal_length ? {imageExif.focal_length} : null} -
- ) : null} - {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( - - - - ) : null} -
-
- ) : null} - -
-
-

Path

- - - -
-

{selectedImage.path}

-
-
- -
- {currentIndex + 1} / {images.length} +
-
-
- + = images.length - 1 || region.regionSelectMode} + onClick={goNext} + /> )} diff --git a/src/components/lightbox/LightboxAlbumMenu.tsx b/src/components/lightbox/LightboxAlbumMenu.tsx new file mode 100644 index 0000000..b61b620 --- /dev/null +++ b/src/components/lightbox/LightboxAlbumMenu.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { Album } from "../../store"; + +interface LightboxAlbumMenuProps { + imageId: number; + albums: Album[]; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + createAlbum: (name: string) => Promise; +} + +export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }: LightboxAlbumMenuProps) { + const [albumMenuOpen, setAlbumMenuOpen] = useState(false); + const [albumAddedTo, setAlbumAddedTo] = useState(null); + const [newAlbumName, setNewAlbumName] = useState(""); + const [albumAdding, setAlbumAdding] = useState(false); + + return ( +
+
+

Albums

+ +
+ {albumMenuOpen ? ( +
+
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + event.preventDefault(); + const name = newAlbumName.trim(); + if (!name || albumAdding) return; + setAlbumAdding(true); + void createAlbum(name) + .then(async (album) => { + await addToAlbum(album.id, [imageId]); + setAlbumAddedTo(album.id); + setNewAlbumName(""); + }) + .catch(() => undefined) + .finally(() => setAlbumAdding(false)); + }} + > + setNewAlbumName(event.target.value)} + disabled={albumAdding} + /> + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/lightbox/LightboxDetailsPanel.tsx b/src/components/lightbox/LightboxDetailsPanel.tsx new file mode 100644 index 0000000..f6a1af2 --- /dev/null +++ b/src/components/lightbox/LightboxDetailsPanel.tsx @@ -0,0 +1,437 @@ +import { MutableRefObject } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { Album, ImageExif, ImageRecord, ImageTag } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { CloseIcon, StarIcon } from "../icons"; +import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from "./format"; +import { LightboxAlbumMenu } from "./LightboxAlbumMenu"; + +interface LightboxDetailsPanelProps { + selectedImage: ImageRecord; + currentIndex: number; + imageCount: number; + imageTags: ImageTag[]; + setImageTags: React.Dispatch>; + imageExif: ImageExif | null; + tagInput: string; + setTagInput: React.Dispatch>; + tagAdding: boolean; + setTagAdding: React.Dispatch>; + tagsExpanded: boolean; + setTagsExpanded: React.Dispatch>; + taggingQueued: boolean; + setTaggingQueued: React.Dispatch>; + currentImageIdRef: MutableRefObject; + albums: Album[]; + canFindSimilar: boolean; + canSearchRegion: boolean; + regionSelectMode: boolean; + regionSearching: boolean; + taggerReady: boolean; + taggerButtonTooltip: string; + closeImage: () => void; + findSimilar: (imageId: number, sourceFolderId: number | null) => Promise; + updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; + addUserTag: (imageId: number, tag: string) => Promise; + removeTag: (tagId: number) => Promise; + queueTaggingForImage: (imageId: number) => Promise; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + createAlbum: (name: string) => Promise; + onToggleRegionMode: () => void; +} + +export function LightboxDetailsPanel({ + selectedImage, + currentIndex, + imageCount, + imageTags, + setImageTags, + imageExif, + tagInput, + setTagInput, + tagAdding, + setTagAdding, + tagsExpanded, + setTagsExpanded, + taggingQueued, + setTaggingQueued, + currentImageIdRef, + albums, + canFindSimilar, + canSearchRegion, + regionSelectMode, + regionSearching, + taggerReady, + taggerButtonTooltip, + closeImage, + findSimilar, + updateImageDetails, + addUserTag, + removeTag, + queueTaggingForImage, + addToAlbum, + createAlbum, + onToggleRegionMode, +}: LightboxDetailsPanelProps) { + const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null; + const hasCameraInfo = + imageExif && + (imageExif.make || + imageExif.model || + imageExif.lens || + imageExif.f_number || + imageExif.exposure_time || + imageExif.iso || + imageExif.focal_length || + (imageExif.gps_lat != null && imageExif.gps_lon != null)); + + return ( +
+
+
+

{selectedImage.filename}

+

Details

+
+
+ + + + + + +
+ +
+ + {canSearchRegion && ( +
+ + + +
+ )} + +
+
+
+

Rating

+
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1; + return ( + + + + ); + })} + {selectedImage.rating > 0 ? ( + + + + ) : null} +
+
+ +
+

Dimensions

+

+ {selectedImage.width && selectedImage.height + ? `${selectedImage.width} x ${selectedImage.height}px` + : "Pending / unavailable"} +

+
+ + {selectedImage.media_kind === "video" ? ( + <> +
+

Duration

+

{formatDuration(selectedImage.duration_ms)}

+
+
+

Video codec

+

{selectedImage.video_codec ?? "Pending / unavailable"}

+
+
+

Audio codec

+

{selectedImage.audio_codec ?? "None / unavailable"}

+
+ {selectedImage.metadata_error ? ( +
+

Metadata

+

{selectedImage.metadata_error}

+
+ ) : null} + + ) : null} + +
+

Type

+

{selectedImage.mime_type}

+
+
+

File size

+

{formatBytes(selectedImage.file_size)}

+
+
+

Modified

+

{formatDate(selectedImage.modified_at)}

+
+
+

Embedding

+

{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}

+ {selectedImage.embedding_error ? ( +

{selectedImage.embedding_error}

+ ) : null} +
+
+ +
+
+

Tags

+
+ {aiRating ? ( + + {aiRating.label} + + ) : null} + {selectedImage.media_kind === "image" ? ( + + + + ) : null} +
+
+ + {imageTags.length > 0 ? ( + <> +
+ {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => ( + + + {tag.tag} + + + + + + ))} +
+ {imageTags.length > 8 && ( + + )} + + ) : ( +

No tags yet

+ )} + +
{ + event.preventDefault(); + const raw = tagInput.trim(); + if (!raw || tagAdding) return; + setTagAdding(true); + const taggedImageId = selectedImage.id; + void addUserTag(taggedImageId, raw) + .then((newTag) => { + if (currentImageIdRef.current !== taggedImageId) return; + setImageTags((prev) => [...prev, newTag]); + setTagInput(""); + }) + .catch(() => undefined) + .finally(() => setTagAdding(false)); + }} + > + setTagInput(event.target.value)} + disabled={tagAdding} + /> + +
+
+ + + + {hasCameraInfo ? ( +
+

Camera

+
+ {imageExif.make || imageExif.model ? ( +

+ {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} +

+ ) : null} + {imageExif.lens ?

{imageExif.lens}

: null} + {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( +
+ {imageExif.f_number ? {imageExif.f_number} : null} + {imageExif.exposure_time ? {imageExif.exposure_time} : null} + {imageExif.iso ? ISO {imageExif.iso} : null} + {imageExif.focal_length ? {imageExif.focal_length} : null} +
+ ) : null} + {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( + + + + ) : null} +
+
+ ) : null} + +
+
+

Path

+ + + +
+

{selectedImage.path}

+
+
+ +
+ {currentIndex + 1} / {imageCount} +
+
+ ); +} diff --git a/src/components/lightbox/LightboxNavButton.tsx b/src/components/lightbox/LightboxNavButton.tsx new file mode 100644 index 0000000..006cf16 --- /dev/null +++ b/src/components/lightbox/LightboxNavButton.tsx @@ -0,0 +1,33 @@ +import { ChevronRightIcon } from "../icons"; + +interface LightboxNavButtonProps { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +} + +export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) { + const isNext = direction === "next"; + + return ( + + ); +} diff --git a/src/components/lightbox/LightboxViewport.tsx b/src/components/lightbox/LightboxViewport.tsx new file mode 100644 index 0000000..31a2748 --- /dev/null +++ b/src/components/lightbox/LightboxViewport.tsx @@ -0,0 +1,164 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { RefObject } from "react"; +import { ImageRecord } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { VideoPlayer } from "../VideoPlayer"; +import { Tooltip } from "../Tooltip"; +import { SelectionOverlay, ViewTransform } from "./types"; +import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from "./viewTransform"; + +interface LightboxViewportProps { + selectedImage: ImageRecord; + imageViewportRef: RefObject; + imgRef: RefObject; + view: ViewTransform; + zoom: number; + regionSelectMode: boolean; + isPanning: boolean; + selectionOverlay: SelectionOverlay | null; + canStartSlideshow: boolean; + onStartSlideshow: () => void; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + setView: React.Dispatch>; + clampPan: (view: ViewTransform) => ViewTransform; +} + +export function LightboxViewport({ + selectedImage, + imageViewportRef, + imgRef, + view, + zoom, + regionSelectMode, + isPanning, + selectionOverlay, + canStartSlideshow, + onStartSlideshow, + onPointerDown, + onPointerMove, + onPointerUp, + setView, + clampPan, +}: LightboxViewportProps) { + return ( +
1 && selectedImage.media_kind === "image" + ? "cursor-grab" + : "" + }`} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + {regionSelectMode && ( +
+
+ + + + Draw a region to search — Esc to cancel +
+
+ )} + + {selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && ( +
+ )} + + + + {selectedImage.media_kind === "video" ? ( + + ) : ( + {selectedImage.filename} + )} + + + + {!regionSelectMode && ( +
+
+ + + + {selectedImage.media_kind === "image" ? ( + <> +
+ + {Math.round(zoom * 100)}% + + + ) : null} +
+
+ )} +
+ ); +} diff --git a/src/components/lightbox/SlideshowView.tsx b/src/components/lightbox/SlideshowView.tsx new file mode 100644 index 0000000..8840a0c --- /dev/null +++ b/src/components/lightbox/SlideshowView.tsx @@ -0,0 +1,174 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ImageRecord } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { Tooltip } from "../Tooltip"; +import { ChevronRightIcon, CloseIcon } from "../icons"; +import { SlideshowMotion } from "./useSlideshow"; + +interface SlideshowViewProps { + selectedImage: ImageRecord; + imageCount: number; + position: number; + controlsShown: boolean; + paused: boolean; + loadingMore: boolean; + motionConfig: SlideshowMotion; + onPointerMove: (event: React.PointerEvent) => void; + onShowControls: () => void; + onExit: () => void; + onGo: (direction: -1 | 1) => void; + onTogglePaused: () => void; +} + +export function SlideshowView({ + selectedImage, + imageCount, + position, + controlsShown, + paused, + loadingMore, + motionConfig, + onPointerMove, + onShowControls, + onExit, + onGo, + onTogglePaused, +}: SlideshowViewProps) { + return ( +
{ + event.stopPropagation(); + onShowControls(); + }} + onPointerMove={onPointerMove} + > + + {selectedImage.media_kind === "image" ? ( + + + + ) : ( + + Finding next image… + + )} + + + +
+ {position} + / + {imageCount} + + {selectedImage.filename} +
+ + + +
+ + +
+ + + + + + + + + +
+
+ + {loadingMore ? ( +
+ Loading more… +
+ ) : null} +
+ ); +} diff --git a/src/components/lightbox/format.ts b/src/components/lightbox/format.ts new file mode 100644 index 0000000..c21c28b --- /dev/null +++ b/src/components/lightbox/format.ts @@ -0,0 +1,56 @@ +import { AiRating } from "../../store"; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDate(iso: string | null): string { + if (!iso) return "Unknown"; + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function formatDuration(durationMs: number | null): string { + if (!durationMs || durationMs <= 0) return "Pending / unavailable"; + const totalSeconds = Math.floor(durationMs / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function embeddingLabel(status: string, model: string | null): string { + if (status === "ready") { + return model ? `Ready (${model})` : "Ready"; + } + if (status === "failed") { + return "Failed"; + } + if (status === "processing") { + return "Processing"; + } + return "Queued"; +} + +export function ratingPill(rating: AiRating): { label: string; className: string } { + switch (rating) { + case "general": + return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" }; + case "sensitive": + return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" }; + case "questionable": + return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" }; + case "explicit": + return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" }; + } +} diff --git a/src/components/lightbox/types.ts b/src/components/lightbox/types.ts new file mode 100644 index 0000000..be266cc --- /dev/null +++ b/src/components/lightbox/types.ts @@ -0,0 +1,26 @@ +export interface DragRect { + startX: number; + startY: number; + endX: number; + endY: number; +} + +export interface ViewTransform { + zoom: number; + panX: number; + panY: number; +} + +export interface NormalizedCrop { + x: number; + y: number; + w: number; + h: number; +} + +export interface SelectionOverlay { + left: number; + top: number; + width: number; + height: number; +} diff --git a/src/components/lightbox/useLightboxMediaDetails.ts b/src/components/lightbox/useLightboxMediaDetails.ts new file mode 100644 index 0000000..f9547e5 --- /dev/null +++ b/src/components/lightbox/useLightboxMediaDetails.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; +import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from "../../store"; + +interface UseLightboxMediaDetailsParams { + selectedImage: ImageRecord | null; + taggerModelStatus: TaggerModelStatus | null; + getImageTags: (imageId: number) => Promise; + getImageExif: (imageId: number) => Promise; + loadTaggerModelStatus: () => Promise; + onSelectedImageReset: () => void; +} + +export function useLightboxMediaDetails({ + selectedImage, + taggerModelStatus, + getImageTags, + getImageExif, + loadTaggerModelStatus, + onSelectedImageReset, +}: UseLightboxMediaDetailsParams) { + const [imageTags, setImageTags] = useState([]); + const [imageExif, setImageExif] = useState(null); + const [tagInput, setTagInput] = useState(""); + const [tagAdding, setTagAdding] = useState(false); + const [tagsExpanded, setTagsExpanded] = useState(false); + const [taggingQueued, setTaggingQueued] = useState(false); + + const currentImageIdRef = useRef(null); + currentImageIdRef.current = selectedImage?.id ?? null; + + useEffect(() => { + setImageTags([]); + setImageExif(null); + setTagInput(""); + setTagsExpanded(false); + setTaggingQueued(false); + onSelectedImageReset(); + }, [onSelectedImageReset, selectedImage?.id]); + + useEffect(() => { + if (!selectedImage) return; + let cancelled = false; + void getImageTags(selectedImage.id) + .then((tags) => { if (!cancelled) setImageTags(tags); }) + .catch(() => { if (!cancelled) setImageTags([]); }); + return () => { cancelled = true; }; + }, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id]); + + useEffect(() => { + if (!selectedImage || selectedImage.media_kind !== "image") { + setImageExif(null); + return; + } + let cancelled = false; + void getImageExif(selectedImage.id) + .then((exif) => { if (!cancelled) setImageExif(exif); }) + .catch(() => { if (!cancelled) setImageExif(null); }); + return () => { cancelled = true; }; + }, [getImageExif, selectedImage?.id, selectedImage?.media_kind]); + + useEffect(() => { + if (selectedImage?.media_kind !== "image" || taggerModelStatus !== null) return; + void loadTaggerModelStatus(); + }, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus]); + + useEffect(() => { + if (selectedImage?.ai_tagged_at) setTaggingQueued(false); + }, [selectedImage?.ai_tagged_at]); + + return { + currentImageIdRef, + imageTags, + setImageTags, + imageExif, + tagInput, + setTagInput, + tagAdding, + setTagAdding, + tagsExpanded, + setTagsExpanded, + taggingQueued, + setTaggingQueued, + }; +} diff --git a/src/components/lightbox/useLightboxNavigation.ts b/src/components/lightbox/useLightboxNavigation.ts new file mode 100644 index 0000000..c99cf10 --- /dev/null +++ b/src/components/lightbox/useLightboxNavigation.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect } from "react"; +import { ImageRecord } from "../../store"; +import { ViewTransform } from "./types"; +import { zoomViewAt } from "./viewTransform"; + +interface UseLightboxNavigationParams { + selectedImage: ImageRecord | null; + images: ImageRecord[]; + currentIndex: number; + slideshowActive: boolean; + regionSelectMode: boolean; + closeImage: () => void; + exitRegionMode: () => void; + exitSlideshow: () => void; + goSlideshow: (direction: -1 | 1) => void; + showSlideshowControls: () => void; + setSlideshowPaused: React.Dispatch>; + openImage: (image: ImageRecord) => void; + setView: React.Dispatch>; + clampPan: (view: ViewTransform) => ViewTransform; +} + +export function useLightboxNavigation({ + selectedImage, + images, + currentIndex, + slideshowActive, + regionSelectMode, + closeImage, + exitRegionMode, + exitSlideshow, + goSlideshow, + showSlideshowControls, + setSlideshowPaused, + openImage, + setView, + clampPan, +}: UseLightboxNavigationParams) { + const goPrev = useCallback(() => { + if (currentIndex > 0) openImage(images[currentIndex - 1]); + }, [currentIndex, images, openImage]); + + const goNext = useCallback(() => { + if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); + }, [currentIndex, images, openImage]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (!selectedImage) return; + if (slideshowActive) { + if (event.key === "Escape") { + event.preventDefault(); + exitSlideshow(); + return; + } + if (event.key === " ") { + event.preventDefault(); + setSlideshowPaused((paused) => !paused); + showSlideshowControls(); + return; + } + if (event.key === "ArrowLeft" && !event.shiftKey) { + event.preventDefault(); + goSlideshow(-1); + return; + } + if (event.key === "ArrowRight" && !event.shiftKey) { + event.preventDefault(); + goSlideshow(1); + return; + } + return; + } + if (event.key === "Escape") { + if (regionSelectMode) { + exitRegionMode(); + } else { + closeImage(); + } + } + if (regionSelectMode) return; + if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); + if (event.key === "ArrowRight" && !event.shiftKey) goNext(); + if (event.key === "+" || event.key === "=") + setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0))); + if (event.key === "-") + setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0))); + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + clampPan, + closeImage, + exitRegionMode, + exitSlideshow, + goNext, + goPrev, + goSlideshow, + regionSelectMode, + selectedImage, + setSlideshowPaused, + setView, + showSlideshowControls, + slideshowActive, + ]); + + return { goPrev, goNext }; +} diff --git a/src/components/lightbox/useRegionSelection.ts b/src/components/lightbox/useRegionSelection.ts new file mode 100644 index 0000000..3051f46 --- /dev/null +++ b/src/components/lightbox/useRegionSelection.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { ImageRecord } from "../../store"; +import { DragRect, ViewTransform } from "./types"; +import { + clampPanToViewport, + MIN_SELECTION_FRACTION, + normaliseRect, + rectToNormalisedCrop, + zoomViewAt, +} from "./viewTransform"; + +interface UseRegionSelectionParams { + selectedImage: ImageRecord | null; + slideshowActive: boolean; + imageViewportRef: RefObject; + imgRef: RefObject; + view: ViewTransform; + setView: Dispatch>; + findSimilarByRegion: ( + imageId: number, + crop: { x: number; y: number; w: number; h: number }, + sourceFolderId: number | null, + ) => Promise; +} + +export function useRegionSelection({ + selectedImage, + slideshowActive, + imageViewportRef, + imgRef, + view, + setView, + findSimilarByRegion, +}: UseRegionSelectionParams) { + const [regionSelectMode, setRegionSelectMode] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragRect, setDragRect] = useState(null); + const [regionSearching, setRegionSearching] = useState(false); + const lastPanPointRef = useRef({ x: 0, y: 0 }); + + const clampPan = useCallback( + (nextView: ViewTransform): ViewTransform => + clampPanToViewport(nextView, imgRef.current, imageViewportRef.current), + [imageViewportRef, imgRef], + ); + + const exitRegionMode = useCallback(() => { + setRegionSelectMode(false); + setIsDragging(false); + setDragRect(null); + }, []); + + useEffect(() => { + const viewport = imageViewportRef.current; + if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return; + + const handleWheel = (event: WheelEvent) => { + if (regionSelectMode) return; + if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; + event.preventDefault(); + const bounds = viewport.getBoundingClientRect(); + const anchorX = event.clientX - (bounds.left + bounds.width / 2); + const anchorY = event.clientY - (bounds.top + bounds.height / 2); + setView((currentView) => { + const delta = event.deltaY < 0 ? 0.15 : -0.15; + const next = Math.min(4, Math.max(0.5, currentView.zoom + delta)); + return clampPan(zoomViewAt(currentView, next, anchorX, anchorY)); + }); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive]); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (!regionSelectMode) { + if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setIsPanning(true); + } + return; + } + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + setDragRect({ + startX: event.clientX, + startY: event.clientY, + endX: event.clientX, + endY: event.clientY, + }); + }, + [regionSelectMode, selectedImage?.media_kind, view.zoom], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (isPanning) { + const dx = event.clientX - lastPanPointRef.current.x; + const dy = event.clientY - lastPanPointRef.current.y; + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setView((currentView) => clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })); + return; + } + if (!isDragging) return; + setDragRect((prev) => + prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, + ); + }, + [clampPan, isDragging, isPanning, setView], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (isPanning) { + event.currentTarget.releasePointerCapture(event.pointerId); + setIsPanning(false); + return; + } + if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { + setIsDragging(false); + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + + const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }; + const crop = rectToNormalisedCrop(finalRect, imgRef.current); + + setIsDragging(false); + setDragRect(null); + + const containerBounds = imageViewportRef.current?.getBoundingClientRect(); + const containerSize = containerBounds + ? Math.min(containerBounds.width, containerBounds.height) + : 500; + const selW = Math.abs(finalRect.endX - finalRect.startX); + const selH = Math.abs(finalRect.endY - finalRect.startY); + if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) { + exitRegionMode(); + return; + } + + exitRegionMode(); + setRegionSearching(true); + + void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id) + .finally(() => setRegionSearching(false)); + }, + [dragRect, exitRegionMode, findSimilarByRegion, imageViewportRef, imgRef, isDragging, isPanning, selectedImage], + ); + + return { + regionSelectMode, + setRegionSelectMode, + isPanning, + regionSearching, + setRegionSearching, + exitRegionMode, + clampPan, + selectionOverlay: isDragging && dragRect ? normaliseRect(dragRect) : null, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/src/components/lightbox/useSlideshow.ts b/src/components/lightbox/useSlideshow.ts new file mode 100644 index 0000000..a8f8762 --- /dev/null +++ b/src/components/lightbox/useSlideshow.ts @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; +import type { Transition } from "framer-motion"; +import { ImageRecord, SlideshowOrder, SlideshowTransition } from "../../store"; +import { IDENTITY_VIEW } from "./viewTransform"; +import { ViewTransform } from "./types"; + +const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; +const SLIDESHOW_CONTROLS_IDLE_MS = 5000; +const SLIDESHOW_LOAD_MORE_THRESHOLD = 3; + +export interface SlideshowMotion { + imageInitial: { opacity: number; filter: string }; + imageAnimate: { opacity: number; filter: string }; + imageExit: { opacity: number; filter: string }; + imageTransition: Transition; + contentInitial: { scale: number; x?: number; y?: number }; + contentAnimate: { scale: number; x?: number; y?: number }; + contentTransition: Transition; +} + +interface UseSlideshowParams { + rootRef: RefObject; + selectedImage: ImageRecord | null; + images: ImageRecord[]; + currentIndex: number; + loadedCount: number; + totalImages: number; + intervalSeconds: number; + order: SlideshowOrder; + transition: SlideshowTransition; + openImage: (image: ImageRecord) => void; + loadMoreImages: () => Promise; + exitRegionMode: () => void; + setView: Dispatch>; +} + +export function useSlideshow({ + rootRef, + selectedImage, + images, + currentIndex, + loadedCount, + totalImages, + intervalSeconds, + order, + transition, + openImage, + loadMoreImages, + exitRegionMode, + setView, +}: UseSlideshowParams) { + const [active, setActive] = useState(false); + const [paused, setPaused] = useState(false); + const [controlsVisible, setControlsVisible] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [motionStep, setMotionStep] = useState(0); + + const controlsIdleTimeoutRef = useRef(null); + const pointerRef = useRef<{ x: number; y: number } | null>(null); + const randomSeenRef = useRef>(new Set()); + + const slideshowImages = useMemo( + () => images.filter((image) => image.media_kind === "image"), + [images], + ); + const slideshowIndex = selectedImage + ? slideshowImages.findIndex((image) => image.id === selectedImage.id) + : -1; + const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1); + const canStart = slideshowImages.length > 0; + + const directions = [ + { x: -1, y: 0.45 }, + { x: 1, y: -0.45 }, + { x: -0.7, y: -0.55 }, + { x: 0.7, y: 0.55 }, + ]; + const direction = directions[motionStep % directions.length]; + const motionConfig: SlideshowMotion = { + imageInitial: { opacity: 0, filter: "blur(8px)" }, + imageAnimate: { opacity: 1, filter: "blur(0px)" }, + imageExit: { opacity: 0, filter: "blur(4px)" }, + imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE }, + contentInitial: + transition === "gentle-motion" + ? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y } + : { scale: 1.012 }, + contentAnimate: + transition === "gentle-motion" + ? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y } + : { scale: 1 }, + contentTransition: + transition === "gentle-motion" + ? { duration: intervalSeconds + 0.75, ease: "linear" } + : { duration: 0.72, ease: SLIDESHOW_EASE }, + }; + + const showControls = useCallback(() => { + if (controlsIdleTimeoutRef.current !== null) { + window.clearTimeout(controlsIdleTimeoutRef.current); + controlsIdleTimeoutRef.current = null; + } + setControlsVisible(true); + if (active) { + controlsIdleTimeoutRef.current = window.setTimeout(() => { + setControlsVisible(false); + controlsIdleTimeoutRef.current = null; + }, SLIDESHOW_CONTROLS_IDLE_MS); + } + }, [active]); + + const exit = useCallback(() => { + setActive(false); + setPaused(false); + setControlsVisible(true); + if (document.fullscreenElement === rootRef.current) { + void document.exitFullscreen().catch(() => undefined); + } + }, [rootRef]); + + const go = useCallback( + (direction: -1 | 1, revealControls = true) => { + if (slideshowImages.length === 0) return; + if (slideshowImages.length === 1) { + openImage(slideshowImages[0]); + return; + } + + const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0; + let nextIndex = + (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; + if (order === "random") { + const currentId = slideshowImages[currentSlideshowIndex]?.id; + let candidates = slideshowImages.filter( + (image) => image.id !== currentId && !randomSeenRef.current.has(image.id), + ); + if (candidates.length === 0) { + randomSeenRef.current = new Set(currentId == null ? [] : [currentId]); + candidates = slideshowImages.filter((image) => image.id !== currentId); + } + const nextImage = candidates[Math.floor(Math.random() * candidates.length)]; + nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id); + randomSeenRef.current.add(nextImage.id); + } + setMotionStep((step) => step + 1); + openImage(slideshowImages[nextIndex]); + if (revealControls) { + showControls(); + } + }, + [openImage, order, showControls, slideshowImages, slideshowIndex], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + const previous = pointerRef.current; + const next = { x: event.clientX, y: event.clientY }; + pointerRef.current = next; + + if (!previous) { + if (!controlsVisible) { + showControls(); + } + return; + } + + if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) { + showControls(); + } + }, + [controlsVisible, showControls], + ); + + const start = useCallback(() => { + if (!selectedImage || slideshowImages.length === 0) return; + + const nextImage = + selectedImage.media_kind === "image" + ? selectedImage + : images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ?? + slideshowImages[0]; + + if (nextImage.id !== selectedImage.id) { + openImage(nextImage); + } + + randomSeenRef.current = new Set([nextImage.id]); + setMotionStep(0); + exitRegionMode(); + setView(IDENTITY_VIEW); + setPaused(false); + setControlsVisible(true); + pointerRef.current = null; + setActive(true); + void rootRef.current?.requestFullscreen().catch(() => undefined); + }, [currentIndex, exitRegionMode, images, openImage, rootRef, selectedImage, setView, slideshowImages]); + + useEffect(() => { + if (selectedImage) return; + setActive(false); + setPaused(false); + setControlsVisible(true); + }, [selectedImage]); + + useEffect(() => { + const handleFullscreenChange = () => { + if (!active) return; + if (document.fullscreenElement !== rootRef.current) { + setActive(false); + setPaused(false); + setControlsVisible(true); + } + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, [active, rootRef]); + + useEffect(() => { + showControls(); + return () => { + if (controlsIdleTimeoutRef.current !== null) { + window.clearTimeout(controlsIdleTimeoutRef.current); + controlsIdleTimeoutRef.current = null; + } + }; + }, [active, paused, showControls]); + + useEffect(() => { + if (!active || paused || slideshowImages.length <= 1) return; + const timeout = window.setTimeout(() => { + go(1, false); + }, intervalSeconds * 1000); + return () => window.clearTimeout(timeout); + }, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length]); + + useEffect(() => { + if ( + !active || + loadingMore || + loadedCount >= totalImages || + slideshowImages.length === 0 || + slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD + ) { + return; + } + + setLoadingMore(true); + void loadMoreImages().finally(() => setLoadingMore(false)); + }, [ + active, + loadedCount, + loadMoreImages, + loadingMore, + slideshowImages.length, + slideshowIndex, + totalImages, + ]); + + return { + active, + paused, + setPaused, + controlsShown: controlsVisible, + loadingMore, + images: slideshowImages, + position, + canStart, + motionConfig, + start, + exit, + go, + showControls, + handlePointerMove, + }; +} diff --git a/src/components/lightbox/viewTransform.ts b/src/components/lightbox/viewTransform.ts new file mode 100644 index 0000000..86ae275 --- /dev/null +++ b/src/components/lightbox/viewTransform.ts @@ -0,0 +1,67 @@ +import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from "./types"; + +export const MIN_SELECTION_FRACTION = 0.02; +export const MIN_ZOOM = 0.5; +export const MAX_ZOOM = 4; +export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; + +export function normaliseRect(r: DragRect): SelectionOverlay { + return { + left: Math.min(r.startX, r.endX), + top: Math.min(r.startY, r.endY), + width: Math.abs(r.endX - r.startX), + height: Math.abs(r.endY - r.startY), + }; +} + +export function rectToNormalisedCrop(rect: DragRect, imgEl: HTMLImageElement): NormalizedCrop | null { + const imgBounds = imgEl.getBoundingClientRect(); + if (imgBounds.width === 0 || imgBounds.height === 0) return null; + + const rawX = Math.min(rect.startX, rect.endX); + const rawY = Math.min(rect.startY, rect.endY); + const rawW = Math.abs(rect.endX - rect.startX); + const rawH = Math.abs(rect.endY - rect.startY); + + const clampedX = Math.max(rawX, imgBounds.left); + const clampedY = Math.max(rawY, imgBounds.top); + const clampedRight = Math.min(rawX + rawW, imgBounds.right); + const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom); + + const croppedW = clampedRight - clampedX; + const croppedH = clampedBottom - clampedY; + + if (croppedW <= 0 || croppedH <= 0) return null; + + return { + x: (clampedX - imgBounds.left) / imgBounds.width, + y: (clampedY - imgBounds.top) / imgBounds.height, + w: croppedW / imgBounds.width, + h: croppedH / imgBounds.height, + }; +} + +export function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { + if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; + const ratio = newZoom / view.zoom; + return { + zoom: newZoom, + panX: anchorX - (anchorX - view.panX) * ratio, + panY: anchorY - (anchorY - view.panY) * ratio, + }; +} + +export function clampPanToViewport( + view: ViewTransform, + img: HTMLImageElement | null, + viewport: HTMLDivElement | null, +): ViewTransform { + if (!img || !viewport) return view; + const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2); + const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2); + return { + ...view, + panX: Math.min(maxX, Math.max(-maxX, view.panX)), + panY: Math.min(maxY, Math.max(-maxY, view.panY)), + }; +}