import { useEffect, useCallback, useRef, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; import { useGalleryStore, ImageTag, AiRating } from "../store"; 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; export function Lightbox() { const selectedImage = useGalleryStore((state) => state.selectedImage); const closeImage = useGalleryStore((state) => state.closeImage); const images = useGalleryStore((state) => state.images); const openImage = useGalleryStore((state) => state.openImage); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion); const similarScope = useGalleryStore((state) => state.similarScope); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const getImageTags = useGalleryStore((state) => state.getImageTags); const addUserTag = useGalleryStore((state) => state.addUserTag); const removeTag = useGalleryStore((state) => state.removeTag); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage); // 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 [zoom, setZoom] = useState(1); const [imageTags, setImageTags] = useState([]); const [tagInput, setTagInput] = useState(""); const [tagAdding, setTagAdding] = useState(false); const [tagsExpanded, setTagsExpanded] = useState(false); const [taggingQueued, setTaggingQueued] = useState(false); // Region selection state const [regionSelectMode, setRegionSelectMode] = useState(false); const [isDragging, setIsDragging] = useState(false); const [dragRect, setDragRect] = useState(null); const [regionSearching, setRegionSearching] = useState(false); const imageViewportRef = useRef(null); const imgRef = useRef(null); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const canFindSimilar = selectedImage?.embedding_status === "ready"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; 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); }, []); useEffect(() => { setZoom(1); setImageTags([]); 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]); // 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(() => { const viewport = imageViewportRef.current; if (!viewport || !selectedImage || selectedImage.media_kind !== "image") 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(); setZoom((value) => { const delta = event.deltaY < 0 ? 0.15 : -0.15; return Math.min(4, Math.max(0.5, value + delta)); }); }; viewport.addEventListener("wheel", handleWheel, { passive: false }); return () => viewport.removeEventListener("wheel", handleWheel); }, [selectedImage, regionSelectMode]); useEffect(() => { const handler = (event: KeyboardEvent) => { if (!selectedImage) return; if (event.key === "Escape") { if (regionSelectMode) { exitRegionMode(); } else { closeImage(); } } if (regionSelectMode) return; // block nav keys during selection if (event.key === "ArrowLeft") goPrev(); if (event.key === "ArrowRight") goNext(); if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25)); if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode]); // ── Region selection pointer handlers ─────────────────────────────────────── const handleRegionPointerDown = useCallback( (event: React.PointerEvent) => { if (!regionSelectMode) return; event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setIsDragging(true); setDragRect({ startX: event.clientX, startY: event.clientY, endX: event.clientX, endY: event.clientY, }); }, [regionSelectMode], ); const handleRegionPointerMove = useCallback( (event: React.PointerEvent) => { if (!isDragging) return; setDragRect((prev) => prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, ); }, [isDragging], ); const handleRegionPointerUp = useCallback( (event: React.PointerEvent) => { 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); const folderId = similarScope === "current_folder" ? selectedImage.folder_id : null; void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id) .finally(() => setRegionSearching(false)); }, [isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode], ); // Build the CSS rect for the selection overlay (viewport-relative) const selectionOverlay = isDragging && dragRect ? normaliseRect(dragRect) : null; return ( {selectedImage ? (
event.stopPropagation()}>
{/* Region selection mode hint */} {regionSelectMode && (
Draw a region to search — Esc to cancel
)} {/* Selection rectangle overlay */} {selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
)} {selectedImage.media_kind === "video" ? (

{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} />

Path

{selectedImage.path}

{currentIndex + 1} / {images.length}
) : null} ); }