From 3242897a3b526f4853293f80f280a5a795731c67 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:23:02 +0100 Subject: [PATCH] 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. --- src/components/Toolbar.tsx | 443 +-------------------- src/components/toolbar/FilterPill.tsx | 28 ++ src/components/toolbar/SortControl.tsx | 28 ++ src/components/toolbar/ToolbarFilters.tsx | 145 +++++++ src/components/toolbar/ToolbarSearch.tsx | 128 ++++++ src/components/toolbar/ToolbarTitle.tsx | 36 ++ src/components/toolbar/ZoomControl.tsx | 33 ++ src/components/toolbar/searchValue.ts | 18 + src/components/toolbar/sortOptions.ts | 26 ++ src/components/toolbar/useToolbarSearch.ts | 154 +++++++ 10 files changed, 610 insertions(+), 429 deletions(-) create mode 100644 src/components/toolbar/FilterPill.tsx create mode 100644 src/components/toolbar/SortControl.tsx create mode 100644 src/components/toolbar/ToolbarFilters.tsx create mode 100644 src/components/toolbar/ToolbarSearch.tsx create mode 100644 src/components/toolbar/ToolbarTitle.tsx create mode 100644 src/components/toolbar/ZoomControl.tsx create mode 100644 src/components/toolbar/searchValue.ts create mode 100644 src/components/toolbar/sortOptions.ts create mode 100644 src/components/toolbar/useToolbarSearch.ts diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 30a0cfe..0ce9311 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -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 ( - - ); -} - -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(null); - const [searchQuery, setSearchQuery] = useState(search); - const [searchPanelOpen, setSearchPanelOpen] = useState(false); - const [tagSuggestions, setTagSuggestions] = useState([]); - const debounceRef = useRef | null>(null); - const suggestDebounceRef = useRef | null>(null); - const searchInputRef = useRef(null); - const searchShellRef = useRef(null); - const userHasTyped = useRef(false); - - const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); - const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media"); - const 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("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 (
- {/* 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 ? ( -
-

No matching tags

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

Search by meaning and visual concepts

-
- ) : null} - - {/* Command hints — only shown when no command is active */} - {showCommandHints ? ( -
- {( - [ - { command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search 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 ? ( +
+

No matching tags

+
+ ) : 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, + }; +}