refactor(toolbar): modularize component
Break down the monolithic Toolbar component into smaller, highly cohesive modules to improve maintainability and separation of concerns. - Extract distinct UI regions into dedicated components (`ToolbarTitle`, `ToolbarSearch`, `SortControl`, `ZoomControl`, `ToolbarFilters`, and `FilterPill`). - Abstract complex search state, debouncing, event listeners, and autocomplete logic into a custom `useToolbarSearch` hook. - Relocate search value formatting and sort configurations to dedicated utility files (`searchValue.ts` and `sortOptions.ts`). - Simplify the main `Toolbar` component to act as a clean presentation orchestrator.
This commit is contained in:
+14
-429
@@ -1,439 +1,24 @@
|
||||
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";
|
||||
import { ColorFilter } from "./ColorFilter";
|
||||
import { Dropdown, useDismissable } from "./menu";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { CloseIcon } from "./icons";
|
||||
|
||||
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 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 (
|
||||
<button
|
||||
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
active
|
||||
? activeClass
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
import { SortControl } from "./toolbar/SortControl";
|
||||
import { ToolbarFilters } from "./toolbar/ToolbarFilters";
|
||||
import { ToolbarSearch } from "./toolbar/ToolbarSearch";
|
||||
import { ToolbarTitle } from "./toolbar/ToolbarTitle";
|
||||
import { useToolbarSearch } from "./toolbar/useToolbarSearch";
|
||||
import { ZoomControl } from "./toolbar/ZoomControl";
|
||||
|
||||
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 colorFilter = useGalleryStore((state) => state.colorFilter);
|
||||
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
|
||||
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
|
||||
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<SearchCommand | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(search);
|
||||
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchShellRef = useRef<HTMLDivElement>(null);
|
||||
const userHasTyped = useRef(false);
|
||||
|
||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
|
||||
const sortOptions = getSortOptions(mediaFilter);
|
||||
const hasActiveSearch = search.trim().length > 0;
|
||||
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
|
||||
const isSimilarResults = collectionTitle === "Similar Images";
|
||||
// Album scope is offered while browsing an album (where it's the default) and
|
||||
// while viewing similar/region results that were launched from an album.
|
||||
const showAlbumScope =
|
||||
activeView === "album" ||
|
||||
(similarSourceAlbumId !== null &&
|
||||
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
|
||||
|
||||
// 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<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
|
||||
});
|
||||
setTagSuggestions(Array.isArray(results) ? 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]);
|
||||
|
||||
useDismissable(searchShellRef, () => setSearchPanelOpen(false));
|
||||
|
||||
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
|
||||
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||
const searchState = useToolbarSearch();
|
||||
|
||||
return (
|
||||
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
{/* Primary row */}
|
||||
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
|
||||
{/* Title + count */}
|
||||
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
|
||||
<h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
|
||||
<span className="text-xs text-gray-600 shrink-0">
|
||||
{loadedCount < totalImages
|
||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||
: totalImages.toLocaleString()}
|
||||
</span>
|
||||
{hasActiveSearch && (
|
||||
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
|
||||
{searchModeLabel(parsedSearch.mode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||
|
||||
<div className="flex h-12 items-center gap-2 px-3 lg:gap-3 lg:px-5">
|
||||
<ToolbarTitle parsedSearch={searchState.parsedSearch} />
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
<div ref={searchShellRef} className="relative">
|
||||
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
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-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
/>
|
||||
{searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Remove search command" anchorToCursor>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
|
||||
>
|
||||
{commandPrefix(searchCommand)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{searchQuery.trim().length > 0 || searchCommand !== null ? (
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Clear search" anchorToCursor>
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
onClick={() => {
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
setTagSuggestions([]);
|
||||
clearSearch();
|
||||
}}
|
||||
>
|
||||
<CloseIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag autocomplete suggestions */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{tagSuggestions.map((entry) => (
|
||||
<button
|
||||
key={entry.tag}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(e) => {
|
||||
// mousedown fires before input blur, so we prevent losing focus
|
||||
e.preventDefault();
|
||||
userHasTyped.current = true;
|
||||
setSearchQuery(entry.tag);
|
||||
setSearch(`/t ${entry.tag}`);
|
||||
setSearchPanelOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
}}
|
||||
>
|
||||
<span className="text-sm text-white/88">{entry.tag}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tag mode with no suggestions yet — show a brief hint */}
|
||||
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/25">No matching tags</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Semantic mode hint */}
|
||||
{searchCommand === "semantic" && searchPanelOpen ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Command hints — only shown when no command is active */}
|
||||
{showCommandHints ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{(
|
||||
[
|
||||
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.prefix}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
userHasTyped.current = true;
|
||||
setSearchCommand(option.command);
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
|
||||
{option.prefix}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200">{option.label}</p>
|
||||
<p className="text-xs text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<Dropdown
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={sortOptions}
|
||||
ariaLabel="Sort order"
|
||||
trigger="ghost"
|
||||
size="md"
|
||||
panelClassName="min-w-44"
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-white/10 shrink-0" />
|
||||
|
||||
{/* Zoom */}
|
||||
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
|
||||
{(["compact", "comfortable", "detail"] as const).map((preset, i) => {
|
||||
const presetSize = tileSizeForZoom(preset);
|
||||
const presetLabel =
|
||||
preset === "compact" ? "" : preset === "comfortable" ? "" : "";
|
||||
return (
|
||||
<Tooltip key={preset} label={`${presetLabel} (${presetSize}px tiles)`} followCursor>
|
||||
<button
|
||||
aria-label={`${presetLabel} (${presetSize}px tiles)`}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
i > 0 ? "border-l border-white/8" : ""
|
||||
} ${
|
||||
zoomPreset === preset
|
||||
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
|
||||
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
}`}
|
||||
onClick={() => setZoomPreset(preset)}
|
||||
>
|
||||
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter row — pills scroll horizontally on narrow widths so they never
|
||||
squash or stack; the color filter stays pinned to the right. */}
|
||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
|
||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
|
||||
{showAlbumScope ? (
|
||||
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
|
||||
) : null}
|
||||
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
|
||||
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
|
||||
{hasAnyFailedEmbeddings ? (
|
||||
<FilterPill
|
||||
label="Failed Embeddings"
|
||||
active={failedEmbeddingsOnly}
|
||||
variant="amber"
|
||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{hasAnyFailedTagging ? (
|
||||
<FilterPill
|
||||
label="Failed Tags"
|
||||
active={failedTaggingOnly}
|
||||
variant="amber"
|
||||
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||
</div>
|
||||
<ColorFilter />
|
||||
<ToolbarSearch searchState={searchState} />
|
||||
<SortControl />
|
||||
<div className="h-4 w-px shrink-0 bg-white/10" />
|
||||
<ZoomControl />
|
||||
</div>
|
||||
<ToolbarFilters />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export 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 (
|
||||
<button
|
||||
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
active
|
||||
? activeClass
|
||||
: "text-gray-500 hover:bg-white/5 hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
import { useGalleryStore } from "../../store";
|
||||
import { Dropdown } from "../menu";
|
||||
import { getSortOptions } from "./sortOptions";
|
||||
|
||||
export function SortControl() {
|
||||
const sort = useGalleryStore((state) => state.sort);
|
||||
const setSort = useGalleryStore((state) => state.setSort);
|
||||
const mediaFilter = useGalleryStore((state) => state.mediaFilter);
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaFilter !== "video" && (sort === "duration_asc" || sort === "duration_desc")) {
|
||||
setSort("date_desc");
|
||||
}
|
||||
}, [mediaFilter, sort, setSort]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={getSortOptions(mediaFilter)}
|
||||
ariaLabel="Sort order"
|
||||
trigger="ghost"
|
||||
size="md"
|
||||
panelClassName="min-w-44"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useGalleryStore } from "../../store";
|
||||
import { ColorFilter } from "../ColorFilter";
|
||||
import { FilterPill } from "./FilterPill";
|
||||
|
||||
export function ToolbarFilters() {
|
||||
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
|
||||
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 colorFilter = useGalleryStore((state) => state.colorFilter);
|
||||
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
|
||||
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((progress) => progress.embedding_failed > 0);
|
||||
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((progress) => progress.tagging_failed > 0);
|
||||
const isSimilarResults = collectionTitle === "Similar Images";
|
||||
const showAlbumScope =
|
||||
activeView === "album" ||
|
||||
(similarSourceAlbumId !== null &&
|
||||
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
|
||||
|
||||
const clearFailedFilters = () => {
|
||||
setFailedEmbeddingsOnly(false);
|
||||
setFailedTaggingOnly(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<FilterPill
|
||||
label="All"
|
||||
active={
|
||||
mediaFilter === "all" &&
|
||||
!favoritesOnly &&
|
||||
!failedEmbeddingsOnly &&
|
||||
!failedTaggingOnly &&
|
||||
minimumRating === 0 &&
|
||||
colorFilter === null
|
||||
}
|
||||
onClick={() => {
|
||||
setMediaFilter("all");
|
||||
setFavoritesOnly(false);
|
||||
setMinimumRating(0);
|
||||
setFailedEmbeddingsOnly(false);
|
||||
setFailedTaggingOnly(false);
|
||||
setColorFilter(null);
|
||||
}}
|
||||
/>
|
||||
<FilterPill
|
||||
label="Images"
|
||||
active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly}
|
||||
onClick={() => {
|
||||
setMediaFilter("image");
|
||||
setFavoritesOnly(false);
|
||||
clearFailedFilters();
|
||||
}}
|
||||
/>
|
||||
<FilterPill
|
||||
label="Videos"
|
||||
active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly}
|
||||
onClick={() => {
|
||||
setMediaFilter("video");
|
||||
setFavoritesOnly(false);
|
||||
clearFailedFilters();
|
||||
}}
|
||||
/>
|
||||
<FilterPill
|
||||
label="Favorites"
|
||||
active={favoritesOnly}
|
||||
onClick={() => {
|
||||
setFavoritesOnly(!favoritesOnly);
|
||||
clearFailedFilters();
|
||||
}}
|
||||
/>
|
||||
<FilterPill
|
||||
label="Rated"
|
||||
active={minimumRating === 1}
|
||||
onClick={() => {
|
||||
setMinimumRating(minimumRating === 1 ? 0 : 1);
|
||||
clearFailedFilters();
|
||||
}}
|
||||
/>
|
||||
<FilterPill
|
||||
label="4★+"
|
||||
active={minimumRating === 4}
|
||||
onClick={() => {
|
||||
setMinimumRating(minimumRating === 4 ? 0 : 4);
|
||||
clearFailedFilters();
|
||||
}}
|
||||
/>
|
||||
{showAlbumScope ? (
|
||||
<FilterPill
|
||||
label="Similar: Album"
|
||||
active={similarScope === "current_album"}
|
||||
onClick={() => setSimilarScope("current_album")}
|
||||
/>
|
||||
) : null}
|
||||
<FilterPill
|
||||
label="Similar: Folder"
|
||||
active={similarScope === "current_folder"}
|
||||
onClick={() => setSimilarScope("current_folder")}
|
||||
/>
|
||||
<FilterPill
|
||||
label="Similar: All"
|
||||
active={similarScope === "all_media"}
|
||||
onClick={() => setSimilarScope("all_media")}
|
||||
/>
|
||||
{hasAnyFailedEmbeddings ? (
|
||||
<FilterPill
|
||||
label="Failed Embeddings"
|
||||
active={failedEmbeddingsOnly}
|
||||
variant="amber"
|
||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{hasAnyFailedTagging ? (
|
||||
<FilterPill
|
||||
label="Failed Tags"
|
||||
active={failedTaggingOnly}
|
||||
variant="amber"
|
||||
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{isSimilarResults ? (
|
||||
<span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">
|
||||
Current similar scope:{" "}
|
||||
{similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ColorFilter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { SearchCommand } from "../../store";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import { CloseIcon } from "../icons";
|
||||
import { useToolbarSearch } from "./useToolbarSearch";
|
||||
|
||||
type ToolbarSearchState = ReturnType<typeof useToolbarSearch>;
|
||||
|
||||
export function ToolbarSearch({ searchState }: { searchState: ToolbarSearchState }) {
|
||||
return (
|
||||
<div ref={searchState.searchShellRef} className="relative">
|
||||
<div className="flex items-center overflow-hidden rounded-lg border border-white/8 bg-white/5">
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={searchState.searchInputRef}
|
||||
type="text"
|
||||
value={searchState.searchQuery}
|
||||
onChange={(event) => searchState.handleSearchChange(event.target.value)}
|
||||
onKeyDown={searchState.handleSearchKeyDown}
|
||||
onFocus={() => searchState.setSearchPanelOpen(true)}
|
||||
placeholder="Search files, or use /s /t"
|
||||
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 transition-colors focus:outline-none lg:w-52 xl:w-64 ${
|
||||
searchState.searchCommand !== null ? "pl-16" : "pl-8"
|
||||
}`}
|
||||
/>
|
||||
{searchState.searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Remove search command" anchorToCursor>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={searchState.removeSearchCommand}
|
||||
>
|
||||
{searchState.commandPrefix(searchState.searchCommand)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{searchState.searchQuery.trim().length > 0 || searchState.searchCommand !== null ? (
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2">
|
||||
<Tooltip label="Clear search" anchorToCursor>
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/5 hover:text-gray-200"
|
||||
onClick={searchState.clearSearchInput}
|
||||
>
|
||||
<CloseIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchState.showTagSuggestions && searchState.tagSuggestions.length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{searchState.tagSuggestions.map((entry) => (
|
||||
<button
|
||||
key={entry.tag}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
searchState.chooseTagSuggestion(entry.tag);
|
||||
}}
|
||||
>
|
||||
<span className="text-sm text-white/88">{entry.tag}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-white/30">
|
||||
{entry.count.toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchState.showTagSuggestions && searchState.tagSuggestions.length === 0 && searchState.searchQuery.trim().length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/25">No matching tags</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchState.searchCommand === "semantic" && searchState.searchPanelOpen ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{searchState.showCommandHints ? (
|
||||
<div className="absolute left-0 top-full z-50 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{(
|
||||
[
|
||||
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.prefix}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
searchState.chooseCommandHint(option.command);
|
||||
}}
|
||||
>
|
||||
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
|
||||
{option.prefix}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200">{option.label}</p>
|
||||
<p className="text-xs text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ParsedSearch, searchModeLabel, useGalleryStore } from "../../store";
|
||||
import { FolderScopeDropdown } from "../FolderScopeDropdown";
|
||||
|
||||
export function ToolbarTitle({ parsedSearch }: { parsedSearch: ParsedSearch }) {
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
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 activeView = useGalleryStore((state) => state.activeView);
|
||||
|
||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
|
||||
const hasActiveSearch = search.trim().length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-w-0 shrink-0 items-baseline gap-2.5">
|
||||
<h2 className="max-w-32 truncate text-[15px] font-semibold text-white lg:max-w-48">{title}</h2>
|
||||
<span className="shrink-0 text-xs text-gray-600">
|
||||
{loadedCount < totalImages
|
||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||
: totalImages.toLocaleString()}
|
||||
</span>
|
||||
{hasActiveSearch ? (
|
||||
<span className="shrink-0 rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300">
|
||||
{searchModeLabel(parsedSearch.mode)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { tileSizeForZoom, useGalleryStore } from "../../store";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
|
||||
export function ZoomControl() {
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
|
||||
return (
|
||||
<div className="flex items-center overflow-hidden rounded-lg border border-white/8 light-theme:border-gray-700/40">
|
||||
{(["compact", "comfortable", "detail"] as const).map((preset, index) => {
|
||||
const presetSize = tileSizeForZoom(preset);
|
||||
const presetLabel = preset === "compact" ? "" : preset === "comfortable" ? "" : "";
|
||||
return (
|
||||
<Tooltip key={preset} label={`${presetLabel} (${presetSize}px tiles)`} followCursor>
|
||||
<button
|
||||
aria-label={`${presetLabel} (${presetSize}px tiles)`}
|
||||
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
index > 0 ? "border-l border-white/8" : ""
|
||||
} ${
|
||||
zoomPreset === preset
|
||||
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
|
||||
: "text-gray-500 hover:bg-white/5 hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
|
||||
}`}
|
||||
onClick={() => setZoomPreset(preset)}
|
||||
>
|
||||
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SearchCommand } from "../../store";
|
||||
|
||||
export function commandPrefix(command: SearchCommand | null): string | null {
|
||||
switch (command) {
|
||||
case "semantic":
|
||||
return "/s";
|
||||
case "tag":
|
||||
return "/t";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function composeSearchValue(command: SearchCommand | null, query: string): string {
|
||||
const prefix = commandPrefix(command);
|
||||
if (!prefix) return query;
|
||||
return query.length > 0 ? `${prefix} ${query}` : prefix;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MediaFilter, SortOrder } from "../../store";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
export function getSortOptions(mediaFilter: MediaFilter) {
|
||||
if (mediaFilter === "video") {
|
||||
return [...BASE_SORT_OPTIONS, ...VIDEO_SORT_OPTIONS];
|
||||
}
|
||||
return BASE_SORT_OPTIONS;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
ExploreTagEntry,
|
||||
parseSearchValue,
|
||||
SearchCommand,
|
||||
useGalleryStore,
|
||||
} from "../../store";
|
||||
import { useDismissable } from "../menu";
|
||||
import { commandPrefix, composeSearchValue } from "./searchValue";
|
||||
|
||||
export function useToolbarSearch() {
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
const setSearch = useGalleryStore((state) => state.setSearch);
|
||||
const clearSearch = useGalleryStore((state) => state.clearSearch);
|
||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||
|
||||
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(search);
|
||||
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchShellRef = useRef<HTMLDivElement>(null);
|
||||
const userHasTyped = useRef(false);
|
||||
|
||||
const hasActiveSearch = search.trim().length > 0;
|
||||
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
|
||||
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
|
||||
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchCommand !== "tag") {
|
||||
setTagSuggestions([]);
|
||||
return;
|
||||
}
|
||||
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
|
||||
suggestDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
|
||||
});
|
||||
setTagSuggestions(Array.isArray(results) ? 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]);
|
||||
|
||||
useDismissable(searchShellRef, () => setSearchPanelOpen(false));
|
||||
|
||||
const handleSearchChange = (nextValue: string) => {
|
||||
userHasTyped.current = true;
|
||||
if (!searchCommand) {
|
||||
const parsed = parseSearchValue(nextValue);
|
||||
if (parsed.prefix) {
|
||||
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
|
||||
setSearchQuery(parsed.query);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSearchQuery(nextValue);
|
||||
};
|
||||
|
||||
const handleSearchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
|
||||
event.preventDefault();
|
||||
setSearchCommand(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSearchInput = () => {
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
setTagSuggestions([]);
|
||||
clearSearch();
|
||||
};
|
||||
|
||||
const removeSearchCommand = () => {
|
||||
setSearchCommand(null);
|
||||
setTagSuggestions([]);
|
||||
};
|
||||
|
||||
const chooseTagSuggestion = (tag: string) => {
|
||||
userHasTyped.current = true;
|
||||
setSearchQuery(tag);
|
||||
setSearch(`/t ${tag}`);
|
||||
setSearchPanelOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
};
|
||||
|
||||
const chooseCommandHint = (command: SearchCommand) => {
|
||||
userHasTyped.current = true;
|
||||
setSearchCommand(command);
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
|
||||
return {
|
||||
chooseCommandHint,
|
||||
chooseTagSuggestion,
|
||||
clearSearchInput,
|
||||
commandPrefix,
|
||||
handleSearchChange,
|
||||
handleSearchKeyDown,
|
||||
hasActiveSearch,
|
||||
parsedSearch,
|
||||
removeSearchCommand,
|
||||
searchCommand,
|
||||
searchInputRef,
|
||||
searchPanelOpen,
|
||||
searchQuery,
|
||||
searchShellRef,
|
||||
setSearchPanelOpen,
|
||||
showCommandHints,
|
||||
showTagSuggestions,
|
||||
tagSuggestions,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user