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:
2026-07-04 19:23:02 +01:00
parent 8f424773d2
commit 3242897a3b
10 changed files with 610 additions and 429 deletions
+14 -429
View File
@@ -1,439 +1,24 @@
import { useEffect, useRef, useState } from "react"; import { SortControl } from "./toolbar/SortControl";
import { invoke } from "@tauri-apps/api/core"; import { ToolbarFilters } from "./toolbar/ToolbarFilters";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { ToolbarSearch } from "./toolbar/ToolbarSearch";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ToolbarTitle } from "./toolbar/ToolbarTitle";
import { ColorFilter } from "./ColorFilter"; import { useToolbarSearch } from "./toolbar/useToolbarSearch";
import { Dropdown, useDismissable } from "./menu"; import { ZoomControl } from "./toolbar/ZoomControl";
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 AZ" },
{ value: "name_desc", label: "Name ZA" },
{ 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;
}
export function Toolbar() { export function Toolbar() {
const search = useGalleryStore((state) => state.search); const searchState = useToolbarSearch();
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;
return ( return (
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl"> <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 h-12 items-center gap-2 px-3 lg:gap-3 lg:px-5">
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5"> <ToolbarTitle parsedSearch={searchState.parsedSearch} />
{/* 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-1" /> <div className="flex-1" />
<ToolbarSearch searchState={searchState} />
{/* Search */} <SortControl />
<div ref={searchShellRef} className="relative"> <div className="h-4 w-px shrink-0 bg-white/10" />
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden"> <ZoomControl />
<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 />
</div> </div>
<ToolbarFilters />
</div> </div>
); );
} }
+28
View File
@@ -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>
);
}
+28
View File
@@ -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"
/>
);
}
+145
View File
@@ -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>
);
}
+128
View File
@@ -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>
);
}
+36
View File
@@ -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}
</>
);
}
+33
View File
@@ -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>
);
}
+18
View File
@@ -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;
}
+26
View File
@@ -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 AZ" },
{ value: "name_desc", label: "Name ZA" },
{ 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;
}
+154
View File
@@ -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,
};
}