feat(discovery): AI tagging, semantic search, similarity, duplicate finder, folder management
Adds a full discovery and organisation layer to the gallery.
## AI Tagger
- WD tagger (ONNX via ort) with DirectML/CPU acceleration
- Per-image and per-folder tag queuing; configurable batch size and
confidence threshold; model downloaded on first use via ureq/zip
- Tags stored with source ('ai' | 'user'); user tags are never
overwritten by AI; AI tags protected from accidental promotion
- Tagger pause/resume per folder; in-flight batches discarded cleanly
on pause or cancellation without leaving jobs stuck in processing
## Semantic & Tag Search
- CLIP text-query embedding via candle (HuggingFace hub model)
- Progressive candidate doubling with filter post-processing so
folder/rating/media-kind filters do not silently under-return results
- Tag search with DB-backed pagination (offset + COUNT total)
- Filename search unchanged; prefix syntax: s:, t:, f: in search bar
## Similarity Search
- HNSW in-memory index (hnsw_rs) with monotonic embedding_revision
counter for cache invalidation; build_index retries if a concurrent
write advances the revision during construction
- Folder-scoped similar search uses brute-force cosine scan (no k limit)
- Region-based similarity: crop an arbitrary rectangle in the lightbox,
embed it with CLIP, find the nearest images globally or within folder
- Pagination for both similar and region results; scope toggle
(all media / current folder) re-runs the query without reopening image
## Duplicate Finder
- Three-phase detection: stat (size) → sample hash (4×16 KB windows)
→ full-file hash (xxh3, large files only) to eliminate false positives
- Filesystem-first deletion: only removes DB rows for files successfully
deleted from disk; failed files remain visible for retry
- Persisted scan cache (SQLite) with invalidation on reindex and deletion;
both global and per-folder scopes invalidated after any deletion
## Tag Cloud & Explore
- Visual cluster explore: k-means over CLIP embeddings, configurable k,
representative thumbnail per cluster; cache keyed on xxh3 of embedding set
- Tag explore: ranked tag list with image counts and representative
thumbnail per tag; invalidated when AI tagging completes or folder removed
- Tag autocomplete for search bar
## Folder Management
- Inline rename (display name only, not OS rename)
- Relocate: file-picker to update folder path; all image paths rewritten
using SUBSTR prefix replacement to avoid corrupting paths that contain
the folder name as a substring
- Missing-folder recovery banner: Locate or Remove, never silent deletion
- Right-click context menu on sidebar items: Reindex, Rename, Locate,
Remove; hover buttons preserved alongside context menu
## Background Tasks
- Per-folder progress panel with embedding, tagging, caption, and
thumbnail stage breakdown
- Retry button per task for failed embeddings and tagging jobs
- Completed-task notifications (system tray via tauri-plugin-notification)
- Scan errors shown inline; WalkDir permission errors protect existing
records from deletion rather than cascading data loss
## Backend correctness
- sqlite-vec DML performed outside transactions throughout (virtual-table
limitation); embedding revision incremented on delete as well as insert
- Tagging job discard checks status = 'processing' so paused jobs reset
to 'pending' are also skipped, not just cancelled ones
- Stale async responses in Lightbox guarded with cancellation flags and
currentImageIdRef for both tag-load and tag-add callbacks
- Duplicate cache cleared on reindex; folder-removal clears vector rows
outside transaction before deleting the folder row
This commit was merged in pull request #8.
This commit is contained in:
+213
-75
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||
|
||||
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: "rating_desc", label: "Highest rated" },
|
||||
{ value: "rating_asc", label: "Lowest rated" },
|
||||
{ value: "size_desc", label: "Largest first" },
|
||||
{ value: "size_asc", label: "Smallest first" },
|
||||
];
|
||||
@@ -116,12 +119,27 @@ function FilterPill({
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
||||
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);
|
||||
@@ -133,20 +151,26 @@ export function Toolbar() {
|
||||
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 similarScope = useGalleryStore((state) => state.similarScope);
|
||||
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(search);
|
||||
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);
|
||||
// Tracks whether the user has typed in the search box at least once.
|
||||
// Prevents the debounce effect from dispatching setSearch on initial mount
|
||||
// when searchValue === search (which would wipe a loadSimilarImages result).
|
||||
const searchShellRef = useRef<HTMLDivElement>(null);
|
||||
const userHasTyped = useRef(false);
|
||||
|
||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||
@@ -154,11 +178,8 @@ export function Toolbar() {
|
||||
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" },
|
||||
];
|
||||
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
|
||||
const isSimilarResults = collectionTitle === "Similar Images";
|
||||
|
||||
// If current sort is video-only but we switched away from video filter, reset to date_desc
|
||||
useEffect(() => {
|
||||
@@ -170,35 +191,62 @@ export function Toolbar() {
|
||||
useEffect(() => {
|
||||
if (!userHasTyped.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
|
||||
debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [searchValue, setSearch]);
|
||||
}, [searchCommand, searchQuery, setSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(search);
|
||||
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(results);
|
||||
} catch {
|
||||
setTagSuggestions([]);
|
||||
}
|
||||
}, 120);
|
||||
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
|
||||
}, [searchCommand, searchQuery, selectedFolderId]);
|
||||
|
||||
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("");
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]);
|
||||
}, [clearSearch, hasActiveSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if (searchShellRef.current?.contains(event.target as Node)) return;
|
||||
setSearchPanelOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
return () => window.removeEventListener("pointerdown", close);
|
||||
}, []);
|
||||
|
||||
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
|
||||
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||
|
||||
return (
|
||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
@@ -212,9 +260,9 @@ export function Toolbar() {
|
||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||
: totalImages.toLocaleString()}
|
||||
</span>
|
||||
{(hasActiveSearch || searchMode === "semantic") && (
|
||||
{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">
|
||||
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"}
|
||||
{searchModeLabel(parsedSearch.mode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -222,55 +270,140 @@ export function Toolbar() {
|
||||
<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) => {
|
||||
userHasTyped.current = true;
|
||||
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"
|
||||
/>
|
||||
{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();
|
||||
<div ref={searchShellRef} className="relative">
|
||||
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
||||
<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);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
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-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
/>
|
||||
{searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||
<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([]); }}
|
||||
title="Remove search command"
|
||||
>
|
||||
{commandPrefix(searchCommand)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{searchQuery.trim().length > 0 || searchCommand !== null ? (
|
||||
<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={() => {
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
setTagSuggestions([]);
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag autocomplete suggestions */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-30 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-30 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-30 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-30 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 AI and user tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
|
||||
] 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 */}
|
||||
@@ -302,10 +435,14 @@ export function Toolbar() {
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
|
||||
<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"
|
||||
@@ -314,6 +451,7 @@ export function Toolbar() {
|
||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user