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" }; } } 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 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); 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); const imageViewportRef = useRef(null); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const canFindSimilar = selectedImage?.embedding_status === "ready"; 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(() => { setZoom(1); setImageTags([]); setTagInput(""); setTagsExpanded(false); setTaggingQueued(false); }, [selectedImage?.id]); useEffect(() => { if (!selectedImage) return; void getImageTags(selectedImage.id) .then(setImageTags) .catch(() => setImageTags([])); }, [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 (!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]); useEffect(() => { const handler = (event: KeyboardEvent) => { if (!selectedImage) return; if (event.key === "Escape") closeImage(); 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]); return ( {selectedImage ? (
event.stopPropagation()}>
{selectedImage.media_kind === "video" ? (

{selectedImage.filename}

Details

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); void addUserTag(selectedImage.id, raw) .then((newTag) => { setImageTags((prev) => [...prev, newTag]); setTagInput(""); }) .catch(() => undefined) .finally(() => setTagAdding(false)); }} > setTagInput(e.target.value)} disabled={tagAdding} />

Path

{selectedImage.path}

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