feat: expand media exploration and tagging controls
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
|
||||
|
||||
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
|
||||
{ value: "date_desc", label: "Newest first" },
|
||||
{ value: "date_asc", label: "Oldest first" },
|
||||
{ value: "name_asc", label: "Name A–Z" },
|
||||
{ value: "name_desc", label: "Name Z–A" },
|
||||
{ value: "rating_desc", label: "Highest rated" },
|
||||
{ value: "rating_asc", label: "Lowest rated" },
|
||||
{ value: "size_desc", label: "Largest first" },
|
||||
{ value: "size_asc", label: "Smallest first" },
|
||||
];
|
||||
@@ -116,12 +119,27 @@ function FilterPill({
|
||||
);
|
||||
}
|
||||
|
||||
function commandPrefix(command: SearchCommand | null): string | null {
|
||||
switch (command) {
|
||||
case "semantic":
|
||||
return "/s";
|
||||
case "tag":
|
||||
return "/t";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function composeSearchValue(command: SearchCommand | null, query: string): string {
|
||||
const prefix = commandPrefix(command);
|
||||
if (!prefix) return query;
|
||||
return query.length > 0 ? `${prefix} ${query}` : prefix;
|
||||
}
|
||||
|
||||
export function Toolbar() {
|
||||
const search = useGalleryStore((state) => state.search);
|
||||
const setSearch = useGalleryStore((state) => state.setSearch);
|
||||
const clearSearch = useGalleryStore((state) => state.clearSearch);
|
||||
const searchMode = useGalleryStore((state) => state.searchMode);
|
||||
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
|
||||
const sort = useGalleryStore((state) => state.sort);
|
||||
const setSort = useGalleryStore((state) => state.setSort);
|
||||
const totalImages = useGalleryStore((state) => state.totalImages);
|
||||
@@ -133,20 +151,26 @@ export function Toolbar() {
|
||||
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
|
||||
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
|
||||
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
|
||||
const minimumRating = useGalleryStore((state) => state.minimumRating);
|
||||
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
|
||||
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
|
||||
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
|
||||
const similarScope = useGalleryStore((state) => state.similarScope);
|
||||
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
|
||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
|
||||
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
|
||||
|
||||
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
|
||||
|
||||
const [searchValue, setSearchValue] = useState(search);
|
||||
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState(search);
|
||||
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
|
||||
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
// Tracks whether the user has typed in the search box at least once.
|
||||
// Prevents the debounce effect from dispatching setSearch on initial mount
|
||||
// when searchValue === search (which would wipe a loadSimilarImages result).
|
||||
const searchShellRef = useRef<HTMLDivElement>(null);
|
||||
const userHasTyped = useRef(false);
|
||||
|
||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||
@@ -154,11 +178,8 @@ export function Toolbar() {
|
||||
const tileSize = tileSizeForZoom(zoomPreset);
|
||||
const sortOptions = getSortOptions(mediaFilter);
|
||||
const hasActiveSearch = search.trim().length > 0;
|
||||
|
||||
const searchModes: { value: SearchMode; label: string }[] = [
|
||||
{ value: "filename", label: "Filename" },
|
||||
{ value: "semantic", label: "Semantic" },
|
||||
];
|
||||
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
|
||||
const isSimilarResults = collectionTitle === "Similar Images";
|
||||
|
||||
// If current sort is video-only but we switched away from video filter, reset to date_desc
|
||||
useEffect(() => {
|
||||
@@ -170,35 +191,62 @@ export function Toolbar() {
|
||||
useEffect(() => {
|
||||
if (!userHasTyped.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200);
|
||||
debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [searchValue, setSearch]);
|
||||
}, [searchCommand, searchQuery, setSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(search);
|
||||
const parsed = parseSearchValue(search);
|
||||
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
|
||||
setSearchQuery(parsed.prefix ? parsed.query : search);
|
||||
}, [search]);
|
||||
|
||||
// Fetch tag suggestions when in tag mode
|
||||
useEffect(() => {
|
||||
if (searchCommand !== "tag") {
|
||||
setTagSuggestions([]);
|
||||
return;
|
||||
}
|
||||
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
|
||||
suggestDebounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
|
||||
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
|
||||
});
|
||||
setTagSuggestions(results);
|
||||
} catch {
|
||||
setTagSuggestions([]);
|
||||
}
|
||||
}, 120);
|
||||
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
|
||||
}, [searchCommand, searchQuery, selectedFolderId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
|
||||
if (isModeToggle) {
|
||||
event.preventDefault();
|
||||
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
|
||||
searchInputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
const searchFocused = activeElement === searchInputRef.current;
|
||||
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
|
||||
event.preventDefault();
|
||||
setSearchValue("");
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
clearSearch();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]);
|
||||
}, [clearSearch, hasActiveSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = (event: PointerEvent) => {
|
||||
if (searchShellRef.current?.contains(event.target as Node)) return;
|
||||
setSearchPanelOpen(false);
|
||||
};
|
||||
window.addEventListener("pointerdown", close);
|
||||
return () => window.removeEventListener("pointerdown", close);
|
||||
}, []);
|
||||
|
||||
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
|
||||
const showCommandHints = !searchCommand && searchPanelOpen;
|
||||
|
||||
return (
|
||||
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
|
||||
@@ -212,9 +260,9 @@ export function Toolbar() {
|
||||
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
|
||||
: totalImages.toLocaleString()}
|
||||
</span>
|
||||
{(hasActiveSearch || searchMode === "semantic") && (
|
||||
{hasActiveSearch && (
|
||||
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
|
||||
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"}
|
||||
{searchModeLabel(parsedSearch.mode)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -222,55 +270,140 @@ export function Toolbar() {
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
||||
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
|
||||
{searchModes.map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
|
||||
searchMode === mode.value
|
||||
? "bg-white/10 text-white"
|
||||
: "text-gray-500 hover:text-gray-200"
|
||||
}`}
|
||||
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
|
||||
onClick={() => setSearchMode(mode.value)}
|
||||
>
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={(event) => {
|
||||
userHasTyped.current = true;
|
||||
setSearchValue(event.target.value);
|
||||
}}
|
||||
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."}
|
||||
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
{searchValue.trim().length > 0 && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
|
||||
title="Clear search"
|
||||
onClick={() => {
|
||||
setSearchValue("");
|
||||
clearSearch();
|
||||
<div ref={searchShellRef} className="relative">
|
||||
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
|
||||
<div className="relative">
|
||||
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 115 11a6 6 0 0112 0z" />
|
||||
</svg>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => {
|
||||
userHasTyped.current = true;
|
||||
const nextValue = event.target.value;
|
||||
if (!searchCommand) {
|
||||
const parsed = parseSearchValue(nextValue);
|
||||
if (parsed.prefix) {
|
||||
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
|
||||
setSearchQuery(parsed.query);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSearchQuery(nextValue);
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
|
||||
event.preventDefault();
|
||||
setSearchCommand(null);
|
||||
}
|
||||
}}
|
||||
onFocus={() => setSearchPanelOpen(true)}
|
||||
placeholder="Search files, or use /s /t"
|
||||
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
|
||||
/>
|
||||
{searchCommand !== null ? (
|
||||
<div className="absolute left-8 top-1/2 -translate-y-1/2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
|
||||
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
|
||||
title="Remove search command"
|
||||
>
|
||||
{commandPrefix(searchCommand)}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{searchQuery.trim().length > 0 || searchCommand !== null ? (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
|
||||
title="Clear search"
|
||||
onClick={() => {
|
||||
setSearchCommand(null);
|
||||
setSearchQuery("");
|
||||
setTagSuggestions([]);
|
||||
clearSearch();
|
||||
}}
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag autocomplete suggestions */}
|
||||
{showTagSuggestions && tagSuggestions.length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{tagSuggestions.map((entry) => (
|
||||
<button
|
||||
key={entry.tag}
|
||||
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(e) => {
|
||||
// mousedown fires before input blur, so we prevent losing focus
|
||||
e.preventDefault();
|
||||
userHasTyped.current = true;
|
||||
setSearchQuery(entry.tag);
|
||||
setSearch(`/t ${entry.tag}`);
|
||||
setSearchPanelOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
}}
|
||||
>
|
||||
<span className="text-sm text-white/88">{entry.tag}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Tag mode with no suggestions yet — show a brief hint */}
|
||||
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/25">No matching tags</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Semantic mode hint */}
|
||||
{searchCommand === "semantic" && searchPanelOpen ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
|
||||
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Command hints — only shown when no command is active */}
|
||||
{showCommandHints ? (
|
||||
<div className="absolute left-0 top-full z-30 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
|
||||
{(
|
||||
[
|
||||
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" },
|
||||
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
|
||||
] as const
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.prefix}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
userHasTyped.current = true;
|
||||
setSearchCommand(option.command);
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
|
||||
{option.prefix}
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200">{option.label}</p>
|
||||
<p className="text-xs text-gray-500">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
@@ -302,10 +435,14 @@ export function Toolbar() {
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex items-center gap-1 px-4 pb-1.5">
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
|
||||
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
|
||||
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
|
||||
{hasAnyFailedEmbeddings ? (
|
||||
<FilterPill
|
||||
label="Failed Embeddings"
|
||||
@@ -314,6 +451,7 @@ export function Toolbar() {
|
||||
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
|
||||
/>
|
||||
) : null}
|
||||
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+479
-38
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user