import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; const GAP = 6; function formatDuration(durationMs: number | null): string | null { if (!durationMs || durationMs <= 0) return null; 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 ContextMenu({ x, y, image, onClose, }: { x: number; y: number; image: ImageRecord; onClose: () => void; }) { const openImage = useGalleryStore((state) => state.openImage); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const similarScope = useGalleryStore((state) => state.similarScope); const canFindSimilar = image.embedding_status === "ready"; return (
event.stopPropagation()} >
Rating
{Array.from({ length: 5 }, (_, index) => { const rating = index + 1; return ( ); })} {image.rating > 0 ? ( ) : null}
); } export function ImageTile({ image, onClick, onContextMenu, }: { image: ImageRecord; onClick: () => void; onContextMenu: (event: React.MouseEvent) => void; }) { const [loaded, setLoaded] = useState(false); const [errored, setErrored] = useState(false); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const similarScope = useGalleryStore((state) => state.similarScope); const canFindSimilar = image.embedding_status === "ready"; const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null; return (
); } export function Gallery() { const images = useGalleryStore((state) => state.images); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); const openImage = useGalleryStore((state) => state.openImage); const totalImages = useGalleryStore((state) => state.totalImages); const loadingImages = useGalleryStore((state) => state.loadingImages); const zoomPreset = useGalleryStore((state) => state.zoomPreset); const search = useGalleryStore((state) => state.search); const collectionTitle = useGalleryStore((state) => state.collectionTitle); const imageLoadError = useGalleryStore((state) => state.imageLoadError); const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); const isSimilarResults = collectionTitle === "Similar Images"; const parsedSearch = parseSearchValue(search); const parentRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); useLayoutEffect(() => { const el = parentRef.current; if (!el) return; setContainerWidth(el.clientWidth); const ro = new ResizeObserver((entries) => { setContainerWidth(entries[0].contentRect.width); }); ro.observe(el); return () => ro.disconnect(); }, []); const tileSize = tileSizeForZoom(zoomPreset); const cols = useMemo( () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), [containerWidth, tileSize], ); const rowCount = Math.ceil(images.length / cols); const estimateSize = useCallback(() => tileSize + GAP, [tileSize]); const virtualizer = useVirtualizer({ count: rowCount, getScrollElement: () => parentRef.current, estimateSize, overscan: 3, paddingStart: GAP, }); useEffect(() => { virtualizer.measure(); }, [cols, virtualizer]); useEffect(() => { parentRef.current?.scrollTo({ top: 0, left: 0 }); }, [galleryScrollResetKey]); const handleScroll = useCallback(() => { const el = parentRef.current; if (!el) return; if (el.scrollTop < 24) return; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; if (nearBottom && !loadingImages && images.length < totalImages) { void loadMoreImages(); } }, [images.length, loadMoreImages, loadingImages, totalImages]); useEffect(() => { const el = parentRef.current; if (!el) return; el.addEventListener("scroll", handleScroll, { passive: true }); return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); useEffect(() => { const close = (event: PointerEvent) => { if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; setContextMenu(null); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setContextMenu(null); }; window.addEventListener("pointerdown", close); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", close); window.removeEventListener("keydown", handleKeyDown); }; }, []); return (
{images.length === 0 && loadingImages ? (

{isSimilarResults ? "Finding similar images" : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 ? `Searching for matches to "${parsedSearch.query}"` : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 ? `Searching tags for "${parsedSearch.query}"` : "Loading media"}

{isSimilarResults ? "Comparing visual embeddings" : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 ? "Semantic search can take a little longer than filename search" : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 ? "Matching against AI and user tags" : "Fetching results"}

) : images.length === 0 && !loadingImages ? (

{imageLoadError ? "Could not load results" : isSimilarResults ? "No similar images found" : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 ? "No semantic matches found" : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 ? "No tag matches found" : "No media found"}

{imageLoadError ? imageLoadError : isSimilarResults ? "This item may be visually isolated, or more embeddings may need to finish processing" : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 ? "Try a broader phrase, or wait for more embeddings to finish processing" : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 ? "Try a shorter tag, or wait for more tagging jobs to finish" : "Try adjusting your filters or add a new folder"}

) : (
{virtualizer.getVirtualItems().map((virtualRow) => { const startIndex = virtualRow.index * cols; const rowImages = images.slice(startIndex, startIndex + cols); return (
{rowImages.map((image) => ( openImage(image)} onContextMenu={(event) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, image }); }} /> ))}
); })}
)} {images.length > 0 && loadingImages ? (
) : null} {contextMenu ? ( setContextMenu(null)} /> ) : null}
); }