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 { 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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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