feat: expand media exploration and tagging controls

This commit is contained in:
2026-04-12 12:18:47 +01:00
parent b2826d1143
commit ff4a568b57
23 changed files with 3206 additions and 798 deletions
+8
View File
@@ -6,6 +6,7 @@ import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
@@ -14,6 +15,7 @@ export default function App() {
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const activeView = useGalleryStore((state) => state.activeView);
@@ -21,6 +23,7 @@ export default function App() {
loadFolders().then(() => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
return loadImages(true);
});
let unlisten: (() => void) | undefined;
@@ -46,6 +49,11 @@ export default function App() {
<BackgroundTasks />
<TagCloud />
</>
) : activeView === "duplicates" ? (
<>
<BackgroundTasks />
<DuplicateFinder />
</>
) : (
<>
<Toolbar />
+57 -25
View File
@@ -58,6 +58,8 @@ export function BackgroundTasks() {
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
@@ -127,6 +129,7 @@ export function BackgroundTasks() {
};
const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
void clearTaggingJobs(id);
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false);
@@ -244,10 +247,35 @@ export function BackgroundTasks() {
.filter((t) => dismissed[t.id] !== t.snapshot);
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
if (tasks.length === 0) return null;
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
const duplicateScanTask: Task | null = duplicateScanning ? {
id: -1,
name: "Duplicate Scan",
stages: [{
label: "Hashing",
detail: duplicateScanProgress
? `${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.scanned / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
hasFailedEmbeddings: false,
hasFailedTagging: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: "",
} : null;
const primary = tasks[0];
const extraCount = tasks.length - 1;
const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks;
if (allTasks.length === 0) return null;
const primary = allTasks[0];
const extraCount = allTasks.length - 1;
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative),
@@ -348,8 +376,8 @@ export function BackgroundTasks() {
</button>
)}
{/* Expand chevron (only when multiple folders) */}
{tasks.length > 1 && (
{/* Expand chevron (only when multiple tasks) */}
{allTasks.length > 1 && (
<svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
@@ -358,22 +386,24 @@ export function BackgroundTasks() {
</svg>
)}
{/* Dismiss */}
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
>
<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>
{/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
>
<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>
{/* Expanded panel — one row per folder */}
{expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{tasks.map((task) => {
{allTasks.map((task) => {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
@@ -453,15 +483,17 @@ export function BackgroundTasks() {
</button>
)}
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={() => dismissTask(task.id, task.snapshot)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{task.id >= 0 && (
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={() => dismissTask(task.id, task.snapshot)}
>
<svg className="h-3 w-3" 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>
{task.currentFile && (
+274
View File
@@ -0,0 +1,274 @@
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length;
const noneSelected = groupSelectedCount === 0;
// "Keep all but the first" — a common quick action
const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((img) => img.id);
// Clear any selection for this group first, then add the ones to delete
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
selectAllDuplicates(toDelete);
};
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
{/* Group header */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={() => {
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
}}
>
Deselect all
</button>
)}
</div>
</div>
{/* Image grid */}
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
title={image.path}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{/* Delete overlay */}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
) : null}
{/* Path tooltip on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
const totalWasted = duplicateGroups.reduce(
(sum, g) => sum + g.file_size * (g.images.length - 1),
0,
);
const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0);
const handleDelete = async () => {
setDeleting(true);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
} catch (e) {
setDeleteResult(String(e));
} finally {
setDeleting(false);
}
};
const progressPercent =
duplicateScanProgress && duplicateScanProgress.total > 0
? Math.round((duplicateScanProgress.scanned / duplicateScanProgress.total) * 100)
: 0;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `Scanning… ${duplicateScanProgress.scanned.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}`
: "Starting scan…"
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? "No duplicates found"
: "Scan your library for identical files"}
</p>
{!duplicateScanning && duplicateLastScanned !== null && (
<p className="mt-0.5 text-[10px] text-white/20">
Last scanned {formatRelativeTime(duplicateLastScanned)}
</p>
)}
</div>
<div className="flex items-center gap-2">
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
Select all duplicates
</button>
)}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={clearDuplicateSelection}
disabled={deleting}
>
Deselect all
</button>
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
</>
) : null}
<button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
</button>
</div>
</div>
{/* Progress bar */}
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div>
{/* Body */}
{duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">Hashing files</span>
</div>
) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
) : duplicateGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
) : (
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-4">
{duplicateGroups.map((group) => (
<DuplicateGroupCard key={group.file_hash} group={group} />
))}
</div>
</div>
)}
</div>
);
}
+94 -80
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
const GAP = 6;
@@ -30,6 +30,7 @@ function ContextMenu({
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready";
return (
@@ -59,7 +60,7 @@ function ContextMenu({
}`}
onClick={async () => {
if (!canFindSimilar) return;
await loadSimilarImages(image.id);
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
@@ -115,6 +116,7 @@ function ImageTile({
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path
@@ -181,6 +183,15 @@ function ImageTile({
</svg>
</div>
)}
{image.rating > 0 && (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
{image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)}
@@ -230,7 +241,7 @@ function ImageTile({
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
void loadSimilarImages(image.id);
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
}}
disabled={!canFindSimilar}
>
@@ -250,11 +261,11 @@ export function Gallery() {
const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const search = useGalleryStore((state) => state.search);
const searchMode = useGalleryStore((state) => state.searchMode);
const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
const isSimilarResults = collectionTitle === "Similar Images";
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
@@ -262,6 +273,7 @@ export function Gallery() {
const handleScroll = useCallback(() => {
const element = parentRef.current;
if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
@@ -295,85 +307,87 @@ export function Gallery() {
};
}, []);
if (images.length === 0 && loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium">
{isSimilarResults
? "Finding similar images"
: searchMode === "semantic" && search.trim().length > 0
? `Searching for matches to "${search}"`
: "Loading media"}
</p>
<p className="text-xs text-white/20 mt-1">
{isSimilarResults
? "Comparing visual embeddings"
: searchMode === "semantic" && search.trim().length > 0
? "Semantic search can take a little longer than filename search"
: "Fetching results"}
</p>
</div>
</div>
);
}
if (images.length === 0 && !loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError
? "Could not load results"
: isSimilarResults
? "No similar images found"
: searchMode === "semantic" && search.trim().length > 0
? "No semantic matches found"
: "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError
? imageLoadError
: isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing"
: searchMode === "semantic" && search.trim().length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing"
: "Try adjusting your filters or add a new folder"}
</p>
</div>
</div>
);
}
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div
className="grid content-start"
style={{
padding: GAP,
gap: GAP,
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
}}
>
{images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium">
{isSimilarResults
? "Finding similar images"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"`
: "Loading media"}
</p>
<p className="text-xs text-white/20 mt-1">
{isSimilarResults
? "Comparing visual embeddings"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Matching against AI and user tags"
: "Fetching results"}
</p>
</div>
</div>
) : images.length === 0 && !loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError
? "Could not load results"
: isSimilarResults
? "No similar images found"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "No semantic matches found"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "No tag matches found"
: "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError
? imageLoadError
: isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish"
: "Try adjusting your filters or add a new folder"}
</p>
</div>
</div>
) : (
<div
className="grid content-start"
style={{
padding: GAP,
gap: GAP,
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
}}
>
{images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
)}
{loadingImages ? (
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
+2 -1
View File
@@ -64,6 +64,7 @@ export function Lightbox() {
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag);
@@ -246,7 +247,7 @@ export function Lightbox() {
}`}
onClick={() => {
if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id);
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
}}
disabled={!canFindSimilar}
>
+443 -337
View File
@@ -1,37 +1,87 @@
import { useEffect, useState } from "react";
import { TaggerAcceleration, useGalleryStore } from "../store";
import { useEffect, useMemo, useState } from "react";
import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store";
type SettingsSection = "tagging" | "library" | "display" | "storage";
type SettingsSection = "workspace" | "workers";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "tagging", label: "AI Tagging", detail: "WD tagger model" },
{ id: "library", label: "Library", detail: "Indexing and scanning" },
{ id: "display", label: "Display", detail: "Gallery preferences" },
{ id: "storage", label: "Storage", detail: "Cache and model files" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "workers", label: "Workers", detail: "Queue activity and background processing" },
];
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className =
tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
: tone === "busy"
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500";
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>{children}</span>;
}
function SectionShell({ eyebrow, title, description, children }: {
eyebrow: string;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<span className={`inline-flex rounded-md border px-2 py-1 text-[11px] font-medium ${className}`}>
{children}
</span>
<section>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null}
<div className="mt-5 space-y-4">{children}</div>
</section>
);
}
function TaggerAccelerationButton({
acceleration,
current,
onSelect,
children,
}: {
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
<p className="text-sm font-medium text-white">{title}</p>
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null}
</div>
<div className="pt-4">{children}</div>
</div>
);
}
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0 first:pt-0 last:pb-0">
<div className="min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function ScopeButton({ scope, current, onSelect, children }: {
scope: TaggingQueueScope;
current: TaggingQueueScope;
onSelect: (scope: TaggingQueueScope) => void;
children: React.ReactNode;
}) {
const active = scope === current;
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
}`}
onClick={() => onSelect(scope)}
>
{children}
</button>
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration;
current: TaggerAcceleration;
onSelect: (acceleration: TaggerAcceleration) => void;
@@ -53,63 +103,32 @@ function TaggerAccelerationButton({
);
}
function SettingsRow({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-5 border-b border-white/[0.07] py-4 last:border-b-0">
<div className="min-w-0">
<p className="text-sm font-medium text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function SectionShell({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3>
<div className="mt-5 border-t border-white/[0.08]">{children}</div>
</div>
);
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("tagging");
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
const taggerModelError = useGalleryStore((state) => state.taggerModelError);
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
@@ -119,49 +138,123 @@ export function SettingsModal() {
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize);
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
useEffect(() => {
if (!settingsOpen) return;
void loadTaggerModelStatus();
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, setSettingsOpen]);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]);
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
const totalQueuedJobs = useMemo(
() => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0),
[mediaJobProgress],
);
if (!settingsOpen) return null;
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
const scopeLabel = selectedFolder ? selectedFolder.name : "all libraries";
const taggerReady = taggerModelStatus?.ready ?? false;
const queueScopeLabel =
taggingQueueScope === "all"
? "all media"
: selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}`
: "no folders selected";
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
const taggerDownloadLabel = taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing WD Tagger..."
: taggerReady
? "Installed"
: "Install model";
const taggerDownloadPercent = taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
const runQueueAction = (action: "queue" | "clear") => {
const selectedIds = taggingQueueFolderIds;
const perform =
taggingQueueScope === "all"
? action === "queue"
? queueTaggingJobs(null)
: clearTaggingJobs(null)
: selectedIds.length > 0
? action === "queue"
? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0);
if (action === "queue") {
setTaggerQueueing(true);
} else {
setTaggerClearing(true);
}
setTaggerQueueStatus(null);
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before running tagging jobs.");
return;
}
setTaggerQueueStatus(
count === 0
? action === "queue"
? "No missing tags found for the current target."
: "No queued tagging jobs to clear for the current target."
: action === "queue"
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
if (action === "queue") {
setTaggerQueueing(false);
} else {
setTaggerClearing(false);
}
});
};
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setSettingsOpen(false)}
>
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div
className="flex h-[min(680px,calc(100vh-56px))] w-full max-w-4xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
className="flex h-[min(760px,calc(100vh-56px))] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
>
<aside className="flex w-56 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
<div className="border-b border-white/[0.07] px-5 py-5">
<p className="text-base font-semibold text-white">Settings</p>
<p className="mt-1 text-xs text-gray-600">Phokus preferences</p>
<p className="mt-1 text-xs text-gray-600">Operational controls for AI workflows</p>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-2">
{SECTIONS.map((section) => (
<button
key={section.id}
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
activeSection === section.id
? "bg-white/10 text-white"
: "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
activeSection === section.id ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
}`}
onClick={() => setActiveSection(section.id)}
>
@@ -173,7 +266,11 @@ export function SettingsModal() {
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center justify-end border-b border-white/[0.07] px-6">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
<div>
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "Workers"}</p>
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}</p>
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={() => setSettingsOpen(false)}
@@ -186,274 +283,283 @@ export function SettingsModal() {
</div>
<div className="flex-1 overflow-y-auto px-7 py-6">
{activeSection === "tagging" ? (() => {
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerDownloadLabel = taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing WD Tagger..."
: taggerReady
? "Downloaded"
: "Download WD Tagger";
const taggerDownloadPercent = taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
return (
<SectionShell eyebrow="AI Tagging" title="WD SwinV2 Tagger v3">
<SettingsRow
title="WD Tagger model"
description={taggerReady ? "Stored locally and available offline." : "Download the model to enable automatic AI tagging."}
>
<div className="flex flex-col items-end gap-2">
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady}
>
{taggerModelProgress ? (
<span
className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200"
style={{ width: `${taggerDownloadPercent}%` }}
/>
) : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
{taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : null}
</div>
</SettingsRow>
<SettingsRow
title="Tagger acceleration"
description="Use DirectML for GPU-accelerated tagging when available."
>
<div className="flex flex-col items-end gap-2">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
<TaggerAccelerationButton
acceleration="auto"
current={taggerAcceleration}
onSelect={(acceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
Auto
</TaggerAccelerationButton>
<TaggerAccelerationButton
acceleration="directml"
current={taggerAcceleration}
onSelect={(acceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
DirectML
</TaggerAccelerationButton>
<TaggerAccelerationButton
acceleration="cpu"
current={taggerAcceleration}
onSelect={(acceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(acceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
CPU
</TaggerAccelerationButton>
</div>
<p className="text-[11px] text-gray-600">
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
</p>
</div>
</SettingsRow>
<SettingsRow
title="Confidence threshold"
description="Tags with confidence below this value are discarded. Lower = more tags, higher = fewer but more accurate."
>
<div className="flex flex-col items-end gap-2">
<div className="flex items-center gap-2">
<input
type="number"
min="0.05"
max="0.99"
step="0.05"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay}
onChange={(e) => setTaggerThresholdDraft(e.target.value)}
onBlur={() => {
const val = parseFloat(thresholdDisplay);
if (!isNaN(val) && val >= 0.05 && val <= 0.99) {
setTaggerThresholdSaving(true);
void setTaggerThreshold(val)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
} else {
setTaggerThresholdDraft(null);
}
}}
/>
</div>
<p className="text-[11px] text-gray-600">
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
</p>
</div>
</SettingsRow>
<SettingsRow
title="Tagging queue"
description={`Generate missing AI tags in ${scopeLabel}. Tags update as the background worker finishes each image.`}
>
<div className="flex flex-col items-end gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => {
setTaggerQueueing(true);
setTaggerQueueStatus(null);
void queueTaggingJobs(selectedFolderId)
.then((queued) => {
setTaggerQueueStatus(
queued === 0
? "No missing tags found."
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerQueueing(false));
}}
disabled={!taggerReady || taggerQueueing || taggerClearing}
>
{taggerQueueing
? "Queueing..."
: selectedFolder
? "Tag this library"
: "Tag all libraries"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => {
setTaggerClearing(true);
setTaggerQueueStatus(null);
void clearTaggingJobs(selectedFolderId)
.then((cleared) => {
setTaggerQueueStatus(
cleared === 0
? "No queued tagging jobs to clear."
: `Cleared ${cleared.toLocaleString()} queued job${cleared === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerClearing(false));
}}
disabled={taggerQueueing || taggerClearing}
>
{taggerClearing
? "Clearing..."
: selectedFolder
? "Clear this queue"
: "Clear all queued tags"}
</button>
</div>
</SettingsRow>
<div className="py-4">
<p className="text-xs font-medium text-gray-400">Model location</p>
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p>
{taggerModelProgress?.current_file ? (
<p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p>
) : null}
{taggerModelError ? (
<p className="mt-3 text-xs text-amber-300">{taggerModelError}</p>
) : null}
{taggerQueueStatus ? (
<p className="mt-3 text-xs text-gray-500">{taggerQueueStatus}</p>
) : null}
{taggerRuntimeProbe ? (
<div className="mt-4 border-t border-white/[0.07] pt-4">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium text-gray-400">Runtime check</p>
<StatusPill tone="ready">Ready</StatusPill>
</div>
<p className="mt-2 text-xs text-gray-600">
Tagger acceleration: {taggerRuntimeProbe.acceleration}
</p>
<div className="mt-3 space-y-2">
<div className="rounded-md border border-white/[0.07] bg-black/20 px-3 py-2">
<p className="break-all text-xs text-gray-400">{taggerRuntimeProbe.session.file}</p>
<p className="mt-1 text-[11px] text-gray-600">
{taggerRuntimeProbe.session.inputs.length} input{taggerRuntimeProbe.session.inputs.length === 1 ? "" : "s"} · {taggerRuntimeProbe.session.outputs.join(", ")}
</p>
{activeSection === "workspace" ? (
<div className="space-y-8">
<SectionShell
eyebrow="AI Workspace"
title="Tagging"
>
<SettingsCard title="Tagging Models">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p>
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
</StatusPill>
</div>
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
</p>
</div>
<div className="flex flex-col items-end gap-2">
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady}
>
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
{taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : null}
</div>
</div>
) : null}
</div>
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<div className="flex flex-col items-end gap-2">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
acceleration={acceleration}
current={taggerAcceleration}
onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true);
void setTaggerAcceleration(nextAcceleration)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
</TaggerAccelerationButton>
))}
</div>
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
</div>
</SettingsRow>
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
<div className="flex flex-col items-end gap-2">
<input
type="number"
min="0.05"
max="0.99"
step="0.05"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={() => {
const value = parseFloat(thresholdDisplay);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdSaving(true);
void setTaggerThreshold(value)
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
} else {
setTaggerThresholdDraft(null);
}
}}
/>
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
</div>
</SettingsRow>
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-2">
<input
type="number"
min="1"
max="100"
step="1"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={batchSizeDisplay}
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeSaving(true);
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeSaving(false);
});
} else {
setTaggerBatchSizeDraft(null);
}
}}
/>
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
</div>
</SettingsRow>
<div>
<p className="text-xs font-medium text-gray-400">Model location</p>
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p>
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-3 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? (
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-medium text-gray-400">Runtime check</p>
<StatusPill tone="ready">Ready</StatusPill>
</div>
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div>
</div>
</div>
</SettingsCard>
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsRow title="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
</SettingsRow>
<div className="border-b border-white/[0.07] py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
</div>
<div className="flex gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={folders.length === 0}
>
Select all
</button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueFolderIds.length === 0}
>
Clear
</button>
</div>
</div>
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
return (
<button
key={folder.id}
type="button"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors ${
active
? "border-emerald-400/30 bg-emerald-500/10 text-white"
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]"
}`}
onClick={() => toggleTaggingQueueFolder(folder.id)}
>
<div>
<p className="text-sm font-medium">{folder.name}</p>
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p>
</div>
<div className="flex items-center gap-2">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
</div>
</button>
);
})}
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div>
</div>
<SettingsRow title="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex flex-col items-end gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
</div>
</SettingsRow>
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsCard>
<SettingsCard title="Captioning">
<div className="rounded-xl border border-dashed border-white/[0.09] bg-black/20 px-4 py-4">
<p className="text-sm font-medium text-white/50">Coming soon</p>
</div>
</SettingsCard>
</SectionShell>
);
})() : null}
</div>
) : (
<div className="space-y-8">
<SectionShell
eyebrow="Workers"
title="Background processing"
>
<SettingsCard title="Queue summary" description="Live totals across all folder workers.">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Tagging queued</p>
<p className="mt-2 text-2xl font-semibold text-white">{totalQueuedJobs.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Selected folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{selectedFolders.length.toLocaleString()}</p>
</div>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Library folders</p>
<p className="mt-2 text-2xl font-semibold text-white">{folders.length.toLocaleString()}</p>
</div>
</div>
</SettingsCard>
{activeSection === "library" ? (
<SectionShell eyebrow="Library" title="Indexing and scanning">
<SettingsRow title="Background workers" description="Folder-level pause controls remain in the background tasks panel.">
<StatusPill tone="muted">Managed per folder</StatusPill>
</SettingsRow>
<SettingsRow title="Reindexing" description="Use the library sidebar to rescan a folder when files change.">
<StatusPill tone="muted">Available</StatusPill>
</SettingsRow>
</SectionShell>
) : null}
{activeSection === "display" ? (
<SectionShell eyebrow="Display" title="Gallery preferences">
<SettingsRow title="Grid density" description="Use the toolbar size control to change thumbnail density.">
<StatusPill tone="muted">Toolbar</StatusPill>
</SettingsRow>
<SettingsRow title="Result view" description="Similar results reset to the top on each new search.">
<StatusPill tone="ready">Enabled</StatusPill>
</SettingsRow>
</SectionShell>
) : null}
{activeSection === "storage" ? (
<SectionShell eyebrow="Storage" title="Local files">
<SettingsRow title="WD Tagger" description="Remove the local tagger model without changing the rest of the library.">
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing || !(taggerModelStatus?.ready ?? false)}
>
Delete model files
</button>
</SettingsRow>
</SectionShell>
) : null}
<SettingsCard title="Worker controls" description="Pause, resume, retry, and per-folder failure details are available in the background tasks panel.">
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
<p className="text-sm text-gray-400">Workers are running in the background.</p>
<StatusPill tone="ready">Live</StatusPill>
</div>
</SettingsCard>
</SectionShell>
</div>
)}
</div>
</main>
</div>
+17
View File
@@ -138,6 +138,23 @@ export function Sidebar() {
Explore
</span>
</div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "duplicates"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("duplicates")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "duplicates" ? "text-white" : ""}`}>
Duplicates
</span>
</div>
</div>
{/* Section label */}
+322 -190
View File
@@ -1,243 +1,375 @@
import { useEffect } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, TagCloudEntry } from "../store";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
// Accent glow colours for the hover ring — cycled by index
const GLOWS: string[] = [
"rgba(59,130,246,0.5)",
"rgba(168,85,247,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(236,72,153,0.5)",
"rgba(6,182,212,0.5)",
"rgba(249,115,22,0.5)",
"rgba(34,197,94,0.5)",
const ACCENTS = [
"#60a5fa",
"#c084fc",
"#4ade80",
"#fbbf24",
"#f472b4",
"#2dd4bf",
"#fb923c",
"#a78bfa",
"#34d399",
"#f87171",
];
function pseudoRandom(seed: number): number {
const x = Math.sin(seed + 1) * 10000;
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
function seeded(n: number): number {
const x = Math.sin(n * 9301 + 49297) * 233280;
return x - Math.floor(x);
}
// Map cluster size to a tile size bucket (px)
function getTileSize(count: number, maxCount: number): number {
if (maxCount === 0) return 72;
const ratio = count / maxCount;
if (ratio > 0.75) return 160;
if (ratio > 0.45) return 128;
if (ratio > 0.22) return 104;
if (ratio > 0.08) return 88;
return 72;
}
function TagButton({
entry,
index,
maxCount,
onSearch,
}: {
interface PlacedNode {
entry: TagCloudEntry;
index: number;
maxCount: number;
onSearch: (imageId: number) => void;
}) {
const size = getTileSize(entry.count, maxCount);
const glow = GLOWS[index % GLOWS.length];
x: number;
y: number;
w: number;
h: number;
accent: string;
driftX: number;
driftY: number;
driftDuration: number;
rotateSeed: number;
}
// Small random rotation for organic feel — larger tiles stay flatter
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10;
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4;
const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4;
const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4;
const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4;
const maxCount = Math.max(...entries.map((e) => e.count));
const cx = containerW / 2;
const cy = containerH / 2;
// Spread ellipse shrinks slightly to leave room for card half-widths at the edges
const spreadX = containerW * 0.42;
const spreadY = containerH * 0.36;
const n = entries.length;
const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null;
// 1. Build initial positions using phyllotaxis spiral
const nodes: PlacedNode[] = entries.map((entry, i) => {
const ratio = Math.max(entry.count / maxCount, 0.08);
// Cards scale from 110px to 230px wide; height is 3/4 of width
const w = 110 + Math.sqrt(ratio) * 120;
const h = w * 0.75;
const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE;
return {
entry,
index: i,
x: cx + Math.cos(angle) * radialRatio * spreadX,
y: cy + Math.sin(angle) * radialRatio * spreadY,
w,
h,
accent: ACCENTS[i % ACCENTS.length],
driftX: (seeded(i + 11) - 0.5) * 18,
driftY: (seeded(i + 17) - 0.5) * 14,
driftDuration: 8 + seeded(i + 23) * 7,
rotateSeed: (seeded(i + 31) - 0.5) * 4,
};
});
// 2. Iterative overlap resolution — no physics, just push apart
const PAD = 24;
for (let iter = 0; iter < 80; iter++) {
for (let a = 0; a < nodes.length; a++) {
const na = nodes[a];
for (let b = a + 1; b < nodes.length; b++) {
const nb = nodes[b];
const dx = nb.x - na.x;
const dy = nb.y - na.y;
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
// Push along the smaller overlap axis
if (overlapX < overlapY) {
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
} else {
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
}
}
// Pull gently back toward anchor to prevent runaway drift
na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05;
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
}
}
// 3. Clamp so cards never poke outside the container
return nodes.map((node) => ({
...node,
x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16),
y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16),
}));
}
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { w, h, accent } = node;
return (
<motion.button
initial={{ opacity: 0, scale: 0.5, rotate: rotation * 2 }}
animate={{ opacity: 1, scale: 1, rotate: rotation }}
className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }}
initial={{ opacity: 0, scale: 0.75 }}
animate={{
opacity: 1,
scale: 1,
x: [0, node.driftX, 0],
y: [0, node.driftY, 0],
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
}}
transition={{
delay: index * 0.025,
type: "spring",
stiffness: 200,
damping: 18,
}}
whileHover={{
scale: 1.12,
rotate: 0,
transition: { type: "spring", stiffness: 400, damping: 22 },
}}
whileTap={{ scale: 0.92 }}
onClick={() => onSearch(entry.representative_image_id)}
title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`}
style={{
width: size,
height: size,
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
borderRadius: 12,
border: "2px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.04)",
cursor: "pointer",
padding: 0,
overflow: "hidden",
position: "relative",
flexShrink: 0,
boxShadow: "none",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
const el = e.currentTarget;
el.style.borderColor = glow;
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
el.style.borderColor = "rgba(255,255,255,0.08)";
el.style.boxShadow = "none";
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
}}
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`}
>
{src ? (
<img
src={src}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
// Fallback placeholder when no thumbnail exists yet
<div
style={{
width: "100%",
height: "100%",
background: "rgba(255,255,255,0.06)",
}}
/>
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
{/* Count badge — bottom-right corner */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
style={{
position: "absolute",
bottom: 5,
right: 5,
background: "rgba(0,0,0,0.6)",
color: "rgba(255,255,255,0.85)",
fontSize: 10,
fontWeight: 600,
lineHeight: 1,
padding: "3px 6px",
borderRadius: 6,
backdropFilter: "blur(4px)",
pointerEvents: "none",
}}
>
{entry.count}
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="flex items-end justify-between gap-2">
<div>
<p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div>
<span
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
>
Open
</span>
</div>
</div>
</motion.button>
);
}
// Actual tag cloud — word size driven by log-scaled frequency
function TagWord({
entry,
index,
logMin,
logRange,
onSearch,
}: {
entry: ExploreTagEntry;
index: number;
logMin: number;
logRange: number;
onSearch: (tag: string) => void;
}) {
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px
const accent = ACCENTS[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7;
return (
<motion.button
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
title={`${entry.tag}${entry.count.toLocaleString()} images`}
>
<span
className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }}
>
{entry.tag}
</span>
<span
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: `${accent}22`, color: accent }}
>
{entry.count.toLocaleString()}
</span>
</motion.button>
);
}
function Spinner() {
return (
<motion.div
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/>
);
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
entries,
onOpen,
}: {
entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
useLayoutEffect(() => {
const el = canvasRef.current;
if (!el) return;
const update = () => {
const r = el.getBoundingClientRect();
setCanvasSize({ w: r.width, h: r.height });
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const nodes = useMemo(
() => buildCloud(entries, canvasSize.w, canvasSize.h),
[entries, canvasSize.w, canvasSize.h],
);
return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden">
<div className="pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => (
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} />
))}
</div>
);
}
export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
const searchByTag = useGalleryStore((state) => state.searchByTag);
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
useEffect(() => {
void loadTagCloud();
}, [selectedFolderId]);
if (exploreMode === "visual") void loadTagCloud();
else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
const maxCount =
tagCloudEntries.length > 0
? Math.max(...tagCloudEntries.map((e) => e.count))
: 1;
const { logMin, logRange } = useMemo(() => {
if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1)));
const lo = Math.min(...logs);
const hi = Math.max(...logs);
return { logMin: lo, logRange: hi - lo || 1 };
}, [exploreTagEntries]);
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return (
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header */}
<motion.div
className="text-center pt-14 pb-8 shrink-0"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35 }}
>
<h2 className="text-[22px] font-semibold text-white/70 tracking-tight mb-2">
Explore your library
</h2>
<p className="text-[13px] text-white/25">
Visual clusters from your photos sized by how many match
</p>
</motion.div>
{/* Loading */}
{tagCloudLoading && (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<motion.svg
className="w-8 h-8 text-white/20"
viewBox="0 0 24 24"
fill="none"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2"
strokeOpacity="0.2"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</motion.svg>
<p className="text-[12px] text-white/20">Clustering your library</p>
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-[15px] font-semibold text-white">Explore</h2>
<p className="mt-0.5 truncate text-[11px] text-white/30">
{loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
: hasEntries
? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
: `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
: exploreMode === "visual"
? "No clusters — images need embeddings first"
: "No tags — run the AI tagger or add tags manually"}
</p>
</div>
<div className="flex shrink-0 rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("visual")}
>
Clusters
</button>
<button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("tags")}
>
Tag Cloud
</button>
</div>
</div>
)}
</div>
{/* Empty state */}
{!tagCloudLoading && tagCloudEntries.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-[13px] text-white/25 text-center max-w-xs leading-relaxed">
No embeddings yet. Add a folder and wait for the embedding worker to
finish, then come back here.
{loading ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar."
: "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
</p>
</div>
)}
{/* Cluster grid */}
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full">
{tagCloudEntries.map((entry, index) => (
<TagButton
key={entry.representative_image_id}
entry={entry}
index={index}
maxCount={maxCount}
onSearch={searchByTag}
/>
))}
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
{exploreTagEntries.map((entry, index) => (
<TagWord
key={entry.tag}
entry={entry}
index={index}
logMin={logMin}
logRange={logRange}
onSearch={searchForTag}
/>
))}
</div>
</div>
)}
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
Grouped by visual similarity · CLIP ViT-B/32
</p>
)}
</div>
);
}
+213 -75
View File
@@ -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 AZ" },
{ value: "name_desc", label: "Name ZA" },
{ 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>
);
+479 -38
View File
@@ -15,10 +15,14 @@ export type MediaKind = "image" | "video";
export type MediaFilter = "all" | MediaKind;
export type ZoomPreset = "compact" | "comfortable" | "detail";
export type SearchMode = "filename" | "semantic";
export type SearchCommand = "filename" | "semantic" | "tag";
export type CaptionAcceleration = "auto" | "cpu" | "directml";
export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder";
export type ExploreMode = "visual" | "tags";
export interface ImageRecord {
id: number;
@@ -115,12 +119,33 @@ export interface ThumbnailBatch {
images: ImageRecord[];
}
export type ActiveView = "gallery" | "explore";
export type ActiveView = "gallery" | "explore" | "duplicates";
export interface TagCloudEntry {
count: number;
representative_image_id: number;
thumbnail_path: string | null;
image_ids: number[];
}
export interface ExploreTagEntry {
tag: string;
count: number;
representative_image_id: number;
thumbnail_path: string | null;
}
export interface DuplicateGroup {
file_hash: string;
file_size: number;
images: ImageRecord[];
}
export interface SimilarImagesPage {
images: ImageRecord[];
offset: number;
limit: number;
has_more: boolean;
}
export interface CaptionModelStatus {
@@ -171,6 +196,12 @@ export interface TaggerRuntimeProbe {
session: TaggerRuntimeSessionProbe;
}
export interface ParsedSearch {
mode: SearchCommand;
query: string;
prefix: string | null;
}
export type SortOrder =
| "date_desc"
| "date_asc"
@@ -178,6 +209,8 @@ export type SortOrder =
| "name_desc"
| "size_desc"
| "size_asc"
| "rating_desc"
| "rating_asc"
| "duration_desc"
| "duration_asc";
@@ -194,17 +227,25 @@ interface GalleryState {
sort: SortOrder;
mediaFilter: MediaFilter;
favoritesOnly: boolean;
minimumRating: number;
failedEmbeddingsOnly: boolean;
zoomPreset: ZoomPreset;
selectedImage: ImageRecord | null;
collectionTitle: string | null;
similarSourceImageId: number | null;
similarSourceFolderId: number | null;
similarHasMore: boolean;
similarScope: SimilarScope;
similarFolderId: number | null;
galleryScrollResetKey: number;
activeView: ActiveView;
exploreMode: ExploreMode;
tagCloudEntries: TagCloudEntry[];
tagCloudLoading: boolean;
tagCloudFolderId: number | null | undefined; // undefined = never loaded
exploreTagEntries: ExploreTagEntry[];
exploreTagLoading: boolean;
exploreTagsFolderId: number | null | undefined;
indexingProgress: Record<number, IndexProgress>;
mediaJobProgress: Record<number, FolderJobProgress>;
cacheDir: string;
@@ -218,6 +259,8 @@ interface GalleryState {
captionDetail: CaptionDetail;
aiCaptionsEnabled: boolean;
settingsOpen: boolean;
taggingQueueScope: TaggingQueueScope;
taggingQueueFolderIds: number[];
taggerModelStatus: TaggerModelStatus | null;
taggerModelPreparing: boolean;
@@ -225,9 +268,16 @@ interface GalleryState {
taggerModelProgress: TaggerModelProgress | null;
taggerAcceleration: TaggerAcceleration;
taggerThreshold: number;
taggerBatchSize: number;
taggerRuntimeProbe: TaggerRuntimeProbe | null;
taggerRuntimeChecking: boolean;
duplicateGroups: DuplicateGroup[];
duplicateScanning: boolean;
duplicateScanProgress: { scanned: number; total: number } | null;
duplicateSelectedIds: Set<number>;
duplicateLastScanned: number | null; // Unix timestamp (seconds)
loadFolders: () => Promise<void>;
loadBackgroundJobProgress: () => Promise<void>;
addFolder: (path: string) => Promise<void>;
@@ -243,14 +293,19 @@ interface GalleryState {
setSort: (sort: SortOrder) => void;
setMediaFilter: (filter: MediaFilter) => void;
setFavoritesOnly: (favoritesOnly: boolean) => void;
setMinimumRating: (minimumRating: number) => void;
setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void;
setZoomPreset: (zoomPreset: ZoomPreset) => void;
openImage: (image: ImageRecord) => void;
closeImage: () => void;
setView: (view: ActiveView) => void;
setExploreMode: (mode: ExploreMode) => void;
loadTagCloud: () => Promise<void>;
searchByTag: (imageId: number) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean) => Promise<void>;
loadExploreTags: () => Promise<void>;
showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>;
prepareCaptionModel: () => Promise<void>;
@@ -268,6 +323,9 @@ interface GalleryState {
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
setAiCaptionsEnabled: (enabled: boolean) => void;
setSettingsOpen: (open: boolean) => void;
setTaggingQueueScope: (scope: TaggingQueueScope) => void;
toggleTaggingQueueFolder: (folderId: number) => void;
setTaggingQueueFolderIds: (folderIds: number[]) => void;
retryFailedEmbeddings: (folderId: number) => Promise<void>;
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
setCacheDir: (dir: string) => void;
@@ -280,10 +338,21 @@ interface GalleryState {
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
loadTaggerThreshold: () => Promise<void>;
setTaggerThreshold: (threshold: number) => Promise<void>;
loadTaggerBatchSize: () => Promise<void>;
setTaggerBatchSize: (batchSize: number) => Promise<void>;
probeTaggerRuntime: () => Promise<void>;
queueTaggingJobs: (folderId?: number | null) => Promise<number>;
queueTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
queueTaggingForImage: (imageId: number) => Promise<number>;
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
scanDuplicates: (folderId?: number | null) => Promise<void>;
toggleDuplicateSelected: (imageId: number) => void;
selectAllDuplicates: (imageIds: number[]) => void;
selectKeepFirstAllGroups: () => void;
clearDuplicateSelection: () => void;
deleteSelectedDuplicates: () => Promise<number>;
getImageTags: (imageId: number) => Promise<ImageTag[]>;
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
removeTag: (tagId: number) => Promise<void>;
@@ -291,6 +360,12 @@ interface GalleryState {
const PAGE_SIZE = 200;
const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled";
const SIMILAR_DISTANCE_THRESHOLD = 0.24;
let galleryRequestToken = 0;
let similarRequestToken = 0;
let tagCloudRequestToken = 0;
let exploreTagRequestToken = 0;
function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false;
@@ -312,19 +387,71 @@ function matchesSearch(image: ImageRecord, search: string): boolean {
return image.filename.toLowerCase().includes(search.toLowerCase());
}
function isDerivedCollectionTitle(collectionTitle: string | null): boolean {
return collectionTitle !== null;
}
export function parseSearchValue(search: string): ParsedSearch {
if (!search.trim()) {
return { mode: "filename", query: "", prefix: null };
}
const slashPrefix = search.match(/^\/([a-z])(?:\s|$)/i);
if (slashPrefix) {
const rawPrefix = slashPrefix[1].toLowerCase();
const query = search.length > 3 ? search.slice(3) : "";
if (rawPrefix === "s") {
return { mode: "semantic", query, prefix: "/s" };
}
if (rawPrefix === "t") {
return { mode: "tag", query, prefix: "/t" };
}
return { mode: "filename", query, prefix: rawPrefix === "f" ? "/f" : null };
}
const trimmed = search.trim();
const match = trimmed.match(/^([a-z]):\s*(.*)$/i);
if (!match) {
return { mode: "filename", query: trimmed, prefix: null };
}
const rawPrefix = match[1].toLowerCase();
const query = match[2].trim();
if (rawPrefix === "s") {
return { mode: "semantic", query, prefix: "s:" };
}
if (rawPrefix === "t") {
return { mode: "tag", query, prefix: "t:" };
}
return { mode: "filename", query, prefix: rawPrefix === "f" ? "f:" : null };
}
export function searchModeLabel(mode: SearchCommand): string {
switch (mode) {
case "semantic":
return "Semantic Search";
case "tag":
return "Tag Search";
default:
return "Filename Search";
}
}
function matchesFilters(
image: ImageRecord,
selectedFolderId: number | null,
mediaFilter: MediaFilter,
favoritesOnly: boolean,
minimumRating: number,
failedEmbeddingsOnly: boolean,
search: string,
): boolean {
const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId;
const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter;
const matchesFavorite = !favoritesOnly || image.favorite;
const matchesRating = image.rating >= minimumRating;
const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed";
return matchesFolder && matchesMedia && matchesFavorite && matchesFailedEmbedding && matchesSearch(image, search);
return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search);
}
function compareNullableNumber(a: number | null, b: number | null): number {
@@ -349,6 +476,10 @@ function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number
return compareNullableNumber(a.file_size, b.file_size);
case "size_desc":
return compareNullableNumber(b.file_size, a.file_size);
case "rating_asc":
return compareNullableNumber(a.rating, b.rating);
case "rating_desc":
return compareNullableNumber(b.rating, a.rating);
case "duration_asc":
return compareNullableNumber(a.duration_ms, b.duration_ms);
case "duration_desc":
@@ -424,17 +555,25 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
sort: "date_desc",
mediaFilter: "all",
favoritesOnly: false,
minimumRating: 0,
failedEmbeddingsOnly: false,
zoomPreset: "comfortable",
selectedImage: null,
collectionTitle: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarScope: "all_media",
similarFolderId: null,
galleryScrollResetKey: 0,
activeView: "gallery",
exploreMode: "visual",
tagCloudEntries: [],
tagCloudLoading: false,
tagCloudFolderId: undefined,
exploreTagEntries: [],
exploreTagLoading: false,
exploreTagsFolderId: undefined,
indexingProgress: {},
mediaJobProgress: {},
cacheDir: "",
@@ -448,6 +587,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
captionDetail: "paragraph",
aiCaptionsEnabled: initialAiCaptionsEnabled(),
settingsOpen: false,
taggingQueueScope: "all",
taggingQueueFolderIds: [],
taggerModelStatus: null,
taggerModelPreparing: false,
@@ -455,14 +596,33 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggerModelProgress: null,
taggerAcceleration: "auto",
taggerThreshold: 0.35,
taggerBatchSize: 8,
taggerRuntimeProbe: null,
taggerRuntimeChecking: false,
duplicateGroups: [],
duplicateScanning: false,
duplicateScanProgress: null,
duplicateSelectedIds: new Set(),
duplicateLastScanned: null,
setCacheDir: (cacheDir) => set({ cacheDir }),
loadFolders: async () => {
const folders = await invoke<Folder[]>("get_folders");
set({ folders });
set((state) => {
const folderIds = new Set(folders.map((folder) => folder.id));
const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId));
return {
folders,
taggingQueueFolderIds:
nextSelected.length > 0
? nextSelected
: state.taggingQueueScope === "selected" && folders.length > 0
? [folders[0].id]
: nextSelected,
};
});
},
loadBackgroundJobProgress: async () => {
@@ -507,29 +667,64 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
loadImages: async (reset = false) => {
const { selectedFolderId, search, searchMode, sort, loadedCount, mediaFilter, favoritesOnly, failedEmbeddingsOnly } = get();
const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get();
const parsedSearch = parseSearchValue(search);
const requestToken = ++galleryRequestToken;
set({ loadingImages: true, imageLoadError: null });
try {
if (searchMode === "semantic" && search.trim()) {
if (parsedSearch.mode === "semantic" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("semantic_search_images", {
params: {
query: search,
query: parsedSearch.query,
folder_id: selectedFolderId,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
limit: PAGE_SIZE,
},
});
if (requestToken !== galleryRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
collectionTitle: `Semantic search: ${search}`,
collectionTitle: `Semantic search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
});
return;
}
if (parsedSearch.mode === "tag" && parsedSearch.query) {
const images = await invoke<ImageRecord[]>("search_images_by_tag", {
params: {
query: parsedSearch.query,
folder_id: selectedFolderId,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
limit: PAGE_SIZE,
},
});
if (requestToken !== galleryRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
collectionTitle: `Tag search: ${parsedSearch.query}`,
selectedFolderId,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
});
return;
}
@@ -543,9 +738,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}>("get_images", {
params: {
folder_id: selectedFolderId,
search: search || null,
search: parsedSearch.query || null,
media_kind: mediaFilter === "all" ? null : mediaFilter,
favorites_only: favoritesOnly,
rating_min: minimumRating > 0 ? minimumRating : null,
embedding_failed_only: failedEmbeddingsOnly,
sort,
offset,
@@ -553,6 +749,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
});
if (requestToken !== galleryRequestToken) return;
set((state) => ({
images: reset ? result.images : [...state.images, ...result.images],
totalImages: result.total,
@@ -560,20 +757,24 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadingImages: false,
collectionTitle: reset ? null : state.collectionTitle,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
}));
} catch (error) {
if (requestToken !== galleryRequestToken) return;
console.error("Failed to load media:", error);
set({ loadingImages: false, imageLoadError: String(error) });
}
},
loadMoreImages: async () => {
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, selectedFolderId } = get();
const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId } = get();
if (loadingImages || loadedCount >= totalImages) return;
if (collectionTitle === "Explore Cluster") return;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, selectedFolderId, false);
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false);
return;
}
await get().loadImages(false);
@@ -614,6 +815,11 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
setMinimumRating: (minimumRating) => {
set({ minimumRating, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
},
setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => {
set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null });
void get().loadImages(true);
@@ -626,59 +832,148 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setView: (activeView) => set({ activeView }),
setExploreMode: (exploreMode) => set({ exploreMode }),
loadTagCloud: async () => {
const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get();
// Skip if already loaded for this folder and not currently loading
if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) {
return;
}
const requestToken = ++tagCloudRequestToken;
set({ tagCloudLoading: true, tagCloudFolderId: selectedFolderId });
try {
const entries = await invoke<TagCloudEntry[]>("get_tag_cloud", {
folderId: selectedFolderId,
});
if (requestToken !== tagCloudRequestToken) return;
set({ tagCloudEntries: entries, tagCloudLoading: false });
} catch (error) {
if (requestToken !== tagCloudRequestToken) return;
console.error("Failed to load tag cloud:", error);
set({ tagCloudLoading: false });
}
},
searchByTag: (imageId) => {
const { selectedFolderId } = get();
set((state) => ({ activeView: "gallery", images: [], loadedCount: 0, loadingImages: true, collectionTitle: "Similar Images", imageLoadError: null, galleryScrollResetKey: state.galleryScrollResetKey + 1 }));
void get().loadSimilarImages(imageId, selectedFolderId);
loadExploreTags: async () => {
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
return;
}
const requestToken = ++exploreTagRequestToken;
set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId });
try {
const entries = await invoke<ExploreTagEntry[]>("get_explore_tags", {
params: { folder_id: selectedFolderId, limit: 48 },
});
if (requestToken !== exploreTagRequestToken) return;
set({ exploreTagEntries: entries, exploreTagLoading: false });
} catch (error) {
if (requestToken !== exploreTagRequestToken) return;
console.error("Failed to load explore tags:", error);
set({ exploreTagLoading: false });
}
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true) => {
const requestedLimit = reset ? PAGE_SIZE : get().loadedCount + PAGE_SIZE;
showVisualCluster: async (imageIds) => {
const requestToken = ++similarRequestToken;
set((state) => ({
images: reset ? [] : get().images,
loadedCount: reset ? 0 : get().loadedCount,
activeView: "gallery",
search: "",
images: [],
totalImages: imageIds.length,
loadedCount: 0,
loadingImages: true,
collectionTitle: "Explore Cluster",
imageLoadError: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarHasMore: false,
similarFolderId: null,
galleryScrollResetKey: state.galleryScrollResetKey + 1,
}));
try {
const images = await invoke<ImageRecord[]>("get_images_by_ids", {
params: { image_ids: imageIds },
});
if (requestToken !== similarRequestToken) return;
set({
images,
totalImages: images.length,
loadedCount: images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Explore Cluster",
});
} catch (error) {
if (requestToken !== similarRequestToken) return;
set({
images: [],
totalImages: 0,
loadedCount: 0,
loadingImages: false,
imageLoadError: String(error),
collectionTitle: "Explore Cluster",
});
}
},
searchForTag: (tag) => {
set({ activeView: "gallery", search: `/t ${tag}`, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, similarFolderId: null, imageLoadError: null });
void get().loadImages(true);
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null) => {
const requestToken = ++similarRequestToken;
const offset = reset ? 0 : get().loadedCount;
const similarScope = folderId === null ? "all_media" : "current_folder";
set((state) => ({
images: reset ? [] : state.images,
loadedCount: reset ? 0 : state.loadedCount,
loadingImages: true,
collectionTitle: "Similar Images",
imageLoadError: null,
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarScope,
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
}));
try {
const images = await invoke<ImageRecord[]>("find_similar_images", {
params: { image_id: imageId, folder_id: folderId ?? null, limit: requestedLimit },
});
const hasMore = images.length >= requestedLimit;
set({
images,
totalImages: hasMore ? images.length + PAGE_SIZE : images.length,
loadedCount: images.length,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Similar Images",
similarSourceImageId: imageId,
similarHasMore: hasMore,
selectedFolderId: folderId ?? null,
selectedImage: reset ? null : get().selectedImage,
try {
const result = await invoke<SimilarImagesPage>("find_similar_images", {
params: {
image_id: imageId,
folder_id: folderId ?? null,
offset,
limit: PAGE_SIZE,
threshold: SIMILAR_DISTANCE_THRESHOLD,
},
});
if (requestToken !== similarRequestToken) return;
set((state) => {
const nextImages = reset ? result.images : [...state.images, ...result.images];
const nextLoadedCount = nextImages.length;
return {
images: nextImages,
totalImages: result.has_more ? nextLoadedCount + 1 : nextLoadedCount,
loadedCount: nextLoadedCount,
loadingImages: false,
imageLoadError: null,
collectionTitle: "Similar Images",
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more,
similarFolderId: folderId ?? null,
similarScope,
selectedImage: reset ? null : state.selectedImage,
};
});
} catch (error) {
if (requestToken !== similarRequestToken) return;
console.error("Failed to load similar images:", error);
set({
images: [],
@@ -688,13 +983,23 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: String(error),
collectionTitle: "Similar Images",
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
selectedFolderId: folderId ?? null,
similarFolderId: folderId ?? null,
similarScope,
selectedImage: null,
});
}
},
setSimilarScope: (similarScope) => {
set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId } = get();
if (similarSourceImageId === null) return;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
},
suggestImageTags: async (imageId) => {
return invoke<string[]>("suggest_image_tags", {
params: { image_id: imageId, limit: 2 },
@@ -833,6 +1138,27 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
setTaggingQueueScope: (taggingQueueScope) => {
set((state) => ({
taggingQueueScope,
taggingQueueFolderIds:
taggingQueueScope === "selected" && state.taggingQueueFolderIds.length === 0 && state.folders.length > 0
? [state.folders[0].id]
: state.taggingQueueFolderIds,
}));
},
toggleTaggingQueueFolder: (folderId) => {
set((state) => {
const next = state.taggingQueueFolderIds.includes(folderId)
? state.taggingQueueFolderIds.filter((id) => id !== folderId)
: [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b);
return { taggingQueueFolderIds: next };
});
},
setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }),
loadTaggerModelStatus: async () => {
try {
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
@@ -874,6 +1200,22 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
set({ taggerThreshold });
},
loadTaggerBatchSize: async () => {
try {
const taggerBatchSize = await invoke<number>("get_tagger_batch_size");
set({ taggerBatchSize });
} catch (error) {
set({ taggerModelError: String(error) });
}
},
setTaggerBatchSize: async (batchSize) => {
const taggerBatchSize = await invoke<number>("set_tagger_batch_size", {
params: { batch_size: batchSize },
});
set({ taggerBatchSize });
},
prepareTaggerModel: async () => {
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
try {
@@ -912,6 +1254,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return queued;
},
queueTaggingJobsForFolders: async (folderIds) => {
const queued = await invoke<number>("queue_tagging_jobs", {
params: { folder_id: null, folder_ids: folderIds, image_id: null },
});
await get().loadBackgroundJobProgress();
return queued;
},
queueTaggingForImage: async (imageId) => {
const queued = await invoke<number>("queue_tagging_jobs", {
params: { folder_id: null, image_id: imageId },
@@ -928,6 +1278,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return cleared;
},
clearTaggingJobsForFolders: async (folderIds) => {
const cleared = await invoke<number>("clear_tagging_jobs", {
params: { folder_id: null, folder_ids: folderIds },
});
await get().loadBackgroundJobProgress();
return cleared;
},
getImageTags: async (imageId) => {
return invoke<ImageTag[]>("get_image_tags", {
params: { image_id: imageId },
@@ -946,6 +1304,73 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
});
},
loadDuplicateScanCache: async (folderId = null) => {
interface CacheResult { groups: DuplicateGroup[]; scanned_at: number }
const cached = await invoke<CacheResult | null>("load_duplicate_scan_cache", { folderId: folderId ?? null });
if (cached) {
set({ duplicateGroups: cached.groups, duplicateLastScanned: cached.scanned_at });
}
},
scanDuplicates: async (folderId = null) => {
const { listen } = await import("@tauri-apps/api/event");
set({ duplicateScanning: true, duplicateGroups: [], duplicateScanProgress: null, duplicateSelectedIds: new Set() });
const unlisten = await listen<[number, number]>("duplicate_scan_progress", (event) => {
const [scanned, total] = event.payload;
set({ duplicateScanProgress: { scanned, total } });
});
try {
const groups = await invoke<DuplicateGroup[]>("find_duplicates", { folderId: folderId ?? null });
set({ duplicateGroups: groups, duplicateLastScanned: Math.floor(Date.now() / 1000) });
} finally {
unlisten();
set({ duplicateScanning: false });
}
},
toggleDuplicateSelected: (imageId) => {
set((state) => {
const next = new Set(state.duplicateSelectedIds);
if (next.has(imageId)) next.delete(imageId);
else next.add(imageId);
return { duplicateSelectedIds: next };
});
},
selectAllDuplicates: (imageIds) => {
set((state) => {
const next = new Set(state.duplicateSelectedIds);
for (const id of imageIds) next.add(id);
return { duplicateSelectedIds: next };
});
},
selectKeepFirstAllGroups: () => {
const { duplicateGroups } = get();
const toMark = new Set<number>();
for (const group of duplicateGroups) {
for (const img of group.images.slice(1)) toMark.add(img.id);
}
set({ duplicateSelectedIds: toMark });
},
clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }),
deleteSelectedDuplicates: async () => {
const { duplicateSelectedIds } = get();
const ids = Array.from(duplicateSelectedIds);
if (ids.length === 0) return 0;
const deleted = await invoke<number>("delete_images_from_disk", { params: { image_ids: ids } });
// Remove deleted images from groups and drop now-trivial groups
set((state) => ({
duplicateSelectedIds: new Set(),
duplicateGroups: state.duplicateGroups
.map((g) => ({ ...g, images: g.images.filter((img) => !duplicateSelectedIds.has(img.id)) }))
.filter((g) => g.images.length > 1),
}));
return deleted;
},
retryFailedEmbeddings: async (folderId) => {
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
await get().loadBackgroundJobProgress();
@@ -979,7 +1404,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
if (progress.done) {
void get().loadFolders();
void get().loadBackgroundJobProgress();
void get().loadImages(true);
if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) {
void get().loadImages(true);
}
setTimeout(() => {
set((state) => {
@@ -1019,12 +1446,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload;
set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
return state;
}
const visibleImages = batch.images.filter((image) =>
matchesFilters(
image,
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly,
state.search,
),
@@ -1049,12 +1481,21 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const batch = event.payload;
set((state) => {
if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") {
const selectedImage =
state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id)
? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage
: state.selectedImage;
return { selectedImage };
}
const visibleImages = batch.images.filter((image) =>
matchesFilters(
image,
state.selectedFolderId,
state.mediaFilter,
state.favoritesOnly,
state.minimumRating,
state.failedEmbeddingsOnly,
state.search,
),