import { useEffect, useCallback, useMemo, useRef, useState } from "react"; import { motion, AnimatePresence } 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"; 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_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, }; } 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 findSimilar = useGalleryStore((state) => state.findSimilar); const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion); 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 albums = useGalleryStore((state) => state.albums); const addToAlbum = useGalleryStore((state) => state.addToAlbum); const createAlbum = useGalleryStore((state) => state.createAlbum); const getImageExif = useGalleryStore((state) => state.getImageExif); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); const loadedCount = useGalleryStore((state) => state.loadedCount); const totalImages = useGalleryStore((state) => state.totalImages); const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds); const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); // 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 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 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 canStartSlideshow = slideshowImages.length > 0; 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); }, []); 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") { nextIndex = Math.floor(Math.random() * (slideshowImages.length - 1)); if (nextIndex >= currentSlideshowIndex) nextIndex += 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); } 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]); // 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); }, [ selectedImage, closeImage, exitSlideshow, goPrev, goNext, goSlideshow, regionSelectMode, exitRegionMode, clampPan, showSlideshowControls, slideshowActive, ]); // ── 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; return ( {selectedImage ? ( {slideshowActive ? (
{ event.stopPropagation(); showSlideshowControls(); }} onPointerMove={handleSlideshowPointerMove} > {selectedImage.media_kind === "image" ? ( ) : ( Finding next image… )}
{slideshowPosition} / {slideshowImages.length} {selectedImage.filename}
{slideshowLoadingMore ? (
Loading more…
) : null}
) : ( <>
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 && (
)} {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}
)} ) : null} ); }