Polish search and embedding UX

- add semantic text search with toolbar mode switching and sqlite-vec query support
- improve embedding progress visibility, failure recovery, and similar-image affordances
- add search clearing and keyboard controls for filename vs semantic search modes
- refine background task interactions and gallery/lightbox embedding states

Refs: #4
This commit is contained in:
2026-04-06 01:45:25 +01:00
parent 51e4c2c1f7
commit c6a66d1ba9
16 changed files with 978 additions and 415 deletions
+224 -84
View File
@@ -1,16 +1,93 @@
import { useEffect, useRef, useState } from "react";
import { tileSizeForZoom, useGalleryStore, SortOrder } from "../store";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store";
const SORT_OPTIONS: { value: SortOrder; label: string }[] = [
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" },
{ value: "name_asc", label: "Name A-Z" },
{ value: "name_desc", label: "Name Z-A" },
{ value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" },
{ value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" },
];
function FilterChip({
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 SortDropdown({
value,
onChange,
options,
}: {
value: SortOrder;
onChange: (v: SortOrder) => void;
options: { value: SortOrder; label: string }[];
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0];
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
}`}
>
<span>{current?.label ?? "Sort"}</span>
<svg
className={`h-3 w-3 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={() => { onChange(option.value); setOpen(false); }}
>
{option.label}
{option.value === value ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
))}
</div>
) : null}
</div>
);
}
function FilterPill({
label,
active,
onClick,
@@ -21,10 +98,10 @@ function FilterChip({
}) {
return (
<button
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? "border-blue-400/50 bg-blue-500/15 text-white"
: "border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white"
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={onClick}
>
@@ -36,6 +113,9 @@ function FilterChip({
export function Toolbar() {
const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch);
const searchMode = useGalleryStore((state) => state.searchMode);
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages);
@@ -52,101 +132,161 @@ export function Toolbar() {
const [searchValue, setSearchValue] = useState(search);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media");
const tileSize = tileSizeForZoom(zoomPreset);
const sortOptions = getSortOptions(mediaFilter);
const hasActiveSearch = search.trim().length > 0;
const searchModes: { value: SearchMode; label: string }[] = [
{ value: "filename", label: "Filename" },
{ value: "semantic", label: "Semantic" },
];
// 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 (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setSearch(searchValue);
}, 200);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchValue, setSearch]);
useEffect(() => {
setSearchValue(search);
}, [search]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
if (isModeToggle) {
event.preventDefault();
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
searchInputRef.current?.focus();
return;
}
const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault();
setSearchValue("");
clearSearch();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]);
return (
<div className="border-b border-white/5 bg-gray-950/60 px-5 py-4 backdrop-blur-xl shrink-0">
<div className="flex flex-wrap items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3">
<h2 className="truncate text-base font-semibold text-white">{title}</h2>
<div className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] uppercase tracking-[0.16em] text-gray-400">
{favoritesOnly ? "Favorites" : mediaFilter === "all" ? "Mixed Library" : mediaFilter}
</div>
</div>
<p className="mt-1 text-xs text-gray-500">
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */}
<div className="flex items-center gap-3 px-5 h-12">
{/* 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-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages
? `Showing ${loadedCount.toLocaleString()} of ${totalImages.toLocaleString()} items`
: `${totalImages.toLocaleString()} items ready`}
</p>
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
: totalImages.toLocaleString()}
</span>
{(hasActiveSearch || searchMode === "semantic") && (
<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">
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"}
</span>
)}
</div>
<div className="relative">
<svg
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500"
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"
<div className="flex-1" />
{/* Search */}
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
{searchModes.map((mode) => (
<button
key={mode.value}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
searchMode === mode.value
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200"
}`}
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
onClick={() => setSearchMode(mode.value)}
>
{mode.label}
</button>
))}
</div>
<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={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors"
/>
</svg>
<input
type="text"
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
placeholder="Search filenames, scenes, references..."
className="w-72 rounded-xl border border-white/10 bg-white/5 py-2 pl-9 pr-4 text-sm text-white placeholder:text-gray-500 focus:border-blue-500 focus:outline-none"
/>
{searchValue.trim().length > 0 && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
title="Clear search"
onClick={() => {
setSearchValue("");
clearSearch();
}}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortOrder)}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:border-blue-500 focus:outline-none"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value} className="bg-gray-900">
{option.label}
</option>
{/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} />
{/* 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">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button
key={preset}
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"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)}
>
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
</button>
))}
</select>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<FilterChip label="All" active={mediaFilter === "all"} onClick={() => setMediaFilter("all")} />
<FilterChip label="Images" active={mediaFilter === "image"} onClick={() => setMediaFilter("image")} />
<FilterChip label="Videos" active={mediaFilter === "video"} onClick={() => setMediaFilter("video")} />
<FilterChip label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
<div className="ml-auto flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-400">
<span className="px-2">Tile {tileSize}px</span>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "compact" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("compact")}
>
S
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "comfortable" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("comfortable")}
>
M
</button>
<button
className={`rounded-full px-2 py-1 ${zoomPreset === "detail" ? "bg-white/10 text-white" : "hover:text-white"}`}
onClick={() => setZoomPreset("detail")}
>
L
</button>
</div>
{/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => setFavoritesOnly(!favoritesOnly)} />
</div>
</div>
);