- {/* 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-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 ? (
-
-
-
-
-
- ) : 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 ? (
-
- ) : 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 user and AI tags" },
- { command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
- ] as const
- ).map((option) => (
-
- ))}
-
- ) : null}
-
-
- {/* Sort */}
-
-
- {/* Divider */}
-
-
- {/* Zoom */}
-
- {(["compact", "comfortable", "detail"] as const).map((preset, i) => {
- const presetSize = tileSizeForZoom(preset);
- const presetLabel =
- preset === "compact" ? "" : preset === "comfortable" ? "" : "";
- return (
-
-
-
- );
- })}
-
-
-
- {/* Filter row — pills scroll horizontally on narrow widths so they never
- squash or stack; the color filter stays pinned to the right. */}
-
-
- { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
- { 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); }} />
- {showAlbumScope ? (
- setSimilarScope("current_album")} />
- ) : null}
- setSimilarScope("current_folder")} />
- setSimilarScope("all_media")} />
- {hasAnyFailedEmbeddings ? (
- setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
- />
- ) : null}
- {hasAnyFailedTagging ? (
- setFailedTaggingOnly(!failedTaggingOnly)}
- />
- ) : null}
- {isSimilarResults ? Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"} : null}
-
-
+
+
+
+
+
);
}
diff --git a/src/components/toolbar/FilterPill.tsx b/src/components/toolbar/FilterPill.tsx
new file mode 100644
index 0000000..219e20e
--- /dev/null
+++ b/src/components/toolbar/FilterPill.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/toolbar/SortControl.tsx b/src/components/toolbar/SortControl.tsx
new file mode 100644
index 0000000..c10bd40
--- /dev/null
+++ b/src/components/toolbar/SortControl.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/toolbar/ToolbarFilters.tsx b/src/components/toolbar/ToolbarFilters.tsx
new file mode 100644
index 0000000..4e8d940
--- /dev/null
+++ b/src/components/toolbar/ToolbarFilters.tsx
@@ -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 (
+
+
+ {
+ setMediaFilter("all");
+ setFavoritesOnly(false);
+ setMinimumRating(0);
+ setFailedEmbeddingsOnly(false);
+ setFailedTaggingOnly(false);
+ setColorFilter(null);
+ }}
+ />
+ {
+ setMediaFilter("image");
+ setFavoritesOnly(false);
+ clearFailedFilters();
+ }}
+ />
+ {
+ setMediaFilter("video");
+ setFavoritesOnly(false);
+ clearFailedFilters();
+ }}
+ />
+ {
+ setFavoritesOnly(!favoritesOnly);
+ clearFailedFilters();
+ }}
+ />
+ {
+ setMinimumRating(minimumRating === 1 ? 0 : 1);
+ clearFailedFilters();
+ }}
+ />
+ {
+ setMinimumRating(minimumRating === 4 ? 0 : 4);
+ clearFailedFilters();
+ }}
+ />
+ {showAlbumScope ? (
+ setSimilarScope("current_album")}
+ />
+ ) : null}
+ setSimilarScope("current_folder")}
+ />
+ setSimilarScope("all_media")}
+ />
+ {hasAnyFailedEmbeddings ? (
+ setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
+ />
+ ) : null}
+ {hasAnyFailedTagging ? (
+ setFailedTaggingOnly(!failedTaggingOnly)}
+ />
+ ) : null}
+ {isSimilarResults ? (
+
+ Current similar scope:{" "}
+ {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/toolbar/ToolbarSearch.tsx b/src/components/toolbar/ToolbarSearch.tsx
new file mode 100644
index 0000000..7e96b73
--- /dev/null
+++ b/src/components/toolbar/ToolbarSearch.tsx
@@ -0,0 +1,128 @@
+import { SearchCommand } from "../../store";
+import { Tooltip } from "../Tooltip";
+import { CloseIcon } from "../icons";
+import { useToolbarSearch } from "./useToolbarSearch";
+
+type ToolbarSearchState = ReturnType
;
+
+export function ToolbarSearch({ searchState }: { searchState: ToolbarSearchState }) {
+ return (
+
+
+
+
+
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 ? (
+
+
+
+
+
+ ) : null}
+ {searchState.searchQuery.trim().length > 0 || searchState.searchCommand !== null ? (
+
+
+
+
+
+ ) : null}
+
+
+
+ {searchState.showTagSuggestions && searchState.tagSuggestions.length > 0 ? (
+
+ {searchState.tagSuggestions.map((entry) => (
+
+ ))}
+
+ ) : null}
+
+ {searchState.showTagSuggestions && searchState.tagSuggestions.length === 0 && searchState.searchQuery.trim().length > 0 ? (
+
+ ) : null}
+
+ {searchState.searchCommand === "semantic" && searchState.searchPanelOpen ? (
+
+
Search by meaning and visual concepts
+
+ ) : null}
+
+ {searchState.showCommandHints ? (
+
+ {(
+ [
+ { 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) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/toolbar/ToolbarTitle.tsx b/src/components/toolbar/ToolbarTitle.tsx
new file mode 100644
index 0000000..923321d
--- /dev/null
+++ b/src/components/toolbar/ToolbarTitle.tsx
@@ -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 (
+ <>
+
+
{title}
+
+ {loadedCount < totalImages
+ ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
+ : totalImages.toLocaleString()}
+
+ {hasActiveSearch ? (
+
+ {searchModeLabel(parsedSearch.mode)}
+
+ ) : null}
+
+
+ {activeView === "timeline" ? : null}
+ >
+ );
+}
diff --git a/src/components/toolbar/ZoomControl.tsx b/src/components/toolbar/ZoomControl.tsx
new file mode 100644
index 0000000..a31a4ff
--- /dev/null
+++ b/src/components/toolbar/ZoomControl.tsx
@@ -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 (
+
+ {(["compact", "comfortable", "detail"] as const).map((preset, index) => {
+ const presetSize = tileSizeForZoom(preset);
+ const presetLabel = preset === "compact" ? "" : preset === "comfortable" ? "" : "";
+ return (
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/toolbar/searchValue.ts b/src/components/toolbar/searchValue.ts
new file mode 100644
index 0000000..08de94b
--- /dev/null
+++ b/src/components/toolbar/searchValue.ts
@@ -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;
+}
diff --git a/src/components/toolbar/sortOptions.ts b/src/components/toolbar/sortOptions.ts
new file mode 100644
index 0000000..1c7fb82
--- /dev/null
+++ b/src/components/toolbar/sortOptions.ts
@@ -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;
+}
diff --git a/src/components/toolbar/useToolbarSearch.ts b/src/components/toolbar/useToolbarSearch.ts
new file mode 100644
index 0000000..ae14b92
--- /dev/null
+++ b/src/components/toolbar/useToolbarSearch.ts
@@ -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(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 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("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) => {
+ 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,
+ };
+}