import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, { value: "date_asc", label: "Oldest first" }, { value: "taken_desc", label: "Taken: newest" }, { value: "taken_asc", label: "Taken: oldest" }, { value: "name_asc", label: "Name A–Z" }, { value: "name_desc", label: "Name Z–A" }, { value: "rating_desc", label: "Highest rated" }, { value: "rating_asc", label: "Lowest rated" }, { value: "size_desc", label: "Largest first" }, { value: "size_asc", label: "Smallest first" }, ]; const VIDEO_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "duration_desc", label: "Longest first" }, { value: "duration_asc", label: "Shortest first" }, ]; function getSortOptions(mediaFilter: MediaFilter) { if (mediaFilter === "video") { return [...BASE_SORT_OPTIONS, ...VIDEO_SORT_OPTIONS]; } return BASE_SORT_OPTIONS; } function SortDropdown({ value, onChange, options, }: { value: SortOrder; onChange: (v: SortOrder) => void; options: { value: SortOrder; label: string }[]; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0]; useEffect(() => { const close = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) setOpen(false); }; window.addEventListener("pointerdown", close); return () => window.removeEventListener("pointerdown", close); }, []); return (
{open ? (
{options.map((option) => ( ))}
) : null}
); } function FilterPill({ label, active, onClick, variant = "default", }: { label: string; active: boolean; onClick: () => void; variant?: "default" | "amber"; }) { const activeClass = variant === "amber" ? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50" : "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"; return ( ); } function commandPrefix(command: SearchCommand | null): string | null { switch (command) { case "semantic": return "/s"; case "tag": return "/t"; default: return null; } } function composeSearchValue(command: SearchCommand | null, query: string): string { const prefix = commandPrefix(command); if (!prefix) return query; return query.length > 0 ? `${prefix} ${query}` : prefix; } export function Toolbar() { const search = useGalleryStore((state) => state.search); const setSearch = useGalleryStore((state) => state.setSearch); const clearSearch = useGalleryStore((state) => state.clearSearch); const sort = useGalleryStore((state) => state.sort); const setSort = useGalleryStore((state) => state.setSort); const totalImages = useGalleryStore((state) => state.totalImages); const loadedCount = useGalleryStore((state) => state.loadedCount); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const collectionTitle = useGalleryStore((state) => state.collectionTitle); const folders = useGalleryStore((state) => state.folders); const mediaFilter = useGalleryStore((state) => state.mediaFilter); const setMediaFilter = useGalleryStore((state) => state.setMediaFilter); const favoritesOnly = useGalleryStore((state) => state.favoritesOnly); const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly); const minimumRating = useGalleryStore((state) => state.minimumRating); const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly); const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly); const similarScope = useGalleryStore((state) => state.similarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const zoomPreset = useGalleryStore((state) => state.zoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const activeView = useGalleryStore((state) => state.activeView); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0); const [searchCommand, setSearchCommand] = useState(null); const [searchQuery, setSearchQuery] = useState(search); const [searchPanelOpen, setSearchPanelOpen] = useState(false); const [tagSuggestions, setTagSuggestions] = useState([]); const debounceRef = useRef | null>(null); const suggestDebounceRef = useRef | null>(null); const searchInputRef = useRef(null); const searchShellRef = useRef(null); const userHasTyped = useRef(false); const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media"); const tileSize = tileSizeForZoom(zoomPreset); const sortOptions = getSortOptions(mediaFilter); const hasActiveSearch = search.trim().length > 0; const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery)); const isSimilarResults = collectionTitle === "Similar Images"; // If current sort is video-only but we switched away from video filter, reset to date_desc useEffect(() => { if (mediaFilter !== "video" && (sort === "duration_asc" || sort === "duration_desc")) { setSort("date_desc"); } }, [mediaFilter, sort, setSort]); useEffect(() => { if (!userHasTyped.current) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchCommand, searchQuery, setSearch]); useEffect(() => { const parsed = parseSearchValue(search); setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null); setSearchQuery(parsed.prefix ? parsed.query : search); }, [search]); // Fetch tag suggestions when in tag mode useEffect(() => { if (searchCommand !== "tag") { setTagSuggestions([]); return; } if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); suggestDebounceRef.current = setTimeout(async () => { try { const results = await invoke("search_tags_autocomplete", { params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, }); setTagSuggestions(results); } catch { setTagSuggestions([]); } }, 120); return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); }; }, [searchCommand, searchQuery, selectedFolderId]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const activeElement = document.activeElement; const searchFocused = activeElement === searchInputRef.current; if (event.key === "Escape" && (searchFocused || hasActiveSearch)) { event.preventDefault(); setSearchCommand(null); setSearchQuery(""); clearSearch(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [clearSearch, hasActiveSearch]); useEffect(() => { const close = (event: PointerEvent) => { if (searchShellRef.current?.contains(event.target as Node)) return; setSearchPanelOpen(false); }; window.addEventListener("pointerdown", close); return () => window.removeEventListener("pointerdown", close); }, []); const showTagSuggestions = searchCommand === "tag" && searchPanelOpen; const showCommandHints = !searchCommand && searchPanelOpen; return (
{/* Primary row */}
{/* Title + count */}

{title}

{loadedCount < totalImages ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` : totalImages.toLocaleString()} {hasActiveSearch && ( {searchModeLabel(parsedSearch.mode)} )}
{activeView === "timeline" ? : null}
{/* Search */}
{ userHasTyped.current = true; const nextValue = event.target.value; if (!searchCommand) { const parsed = parseSearchValue(nextValue); if (parsed.prefix) { setSearchCommand(parsed.mode === "filename" ? null : parsed.mode); setSearchQuery(parsed.query); return; } } setSearchQuery(nextValue); }} onKeyDown={(event) => { if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) { event.preventDefault(); setSearchCommand(null); } }} onFocus={() => setSearchPanelOpen(true)} placeholder="Search files, or use /s /t" className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`} /> {searchCommand !== null ? (
) : null} {searchQuery.trim().length > 0 || searchCommand !== null ? ( ) : null}
{/* Tag autocomplete suggestions */} {showTagSuggestions && tagSuggestions.length > 0 ? (
{tagSuggestions.map((entry) => ( ))}
) : null} {/* Tag mode with no suggestions yet — show a brief hint */} {showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (

No matching tags

) : null} {/* Semantic mode hint */} {searchCommand === "semantic" && searchPanelOpen ? (

Search by meaning and visual concepts

) : null} {/* Command hints — only shown when no command is active */} {showCommandHints ? (
{( [ { command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" }, { command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" }, ] as const ).map((option) => ( ))}
) : null}
{/* Sort */} {/* Divider */}
{/* Zoom */}
{(["compact", "comfortable", "detail"] as const).map((preset, i) => ( ))}
{/* Filter row */}
{ setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> setSimilarScope("current_folder")} /> setSimilarScope("all_media")} /> {hasAnyFailedEmbeddings ? ( setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} /> ) : null} {hasAnyFailedTagging ? ( setFailedTaggingOnly(!failedTaggingOnly)} /> ) : null} {isSimilarResults ? Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"} : null}
); }