import { useEffect, useMemo, useRef, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
type SettingsSection = "workspace" | "general";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "general", label: "General", detail: "App data and diagnostics" },
];
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";
return {children} ;
}
function SectionShell({ eyebrow, title, description, children }: {
eyebrow: string;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
{eyebrow}
{title}
{description ? {description}
: null}
{children}
);
}
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
return (
{title}
{description ?
{description}
: null}
{children}
);
}
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return (
);
}
function ScopeButton({ scope, current, onSelect, children }: {
scope: TaggingQueueScope;
current: TaggingQueueScope;
onSelect: (scope: TaggingQueueScope) => void;
children: React.ReactNode;
}) {
const active = scope === current;
return (
onSelect(scope)}
>
{children}
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration;
current: TaggerAcceleration;
onSelect: (acceleration: TaggerAcceleration) => void;
children: React.ReactNode;
}) {
const active = acceleration === current;
return (
onSelect(acceleration)}
>
{children}
);
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState("workspace");
const [taggerQueueStatus, setTaggerQueueStatus] = useState(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState(null);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null);
const [openingDataFolder, setOpeningDataFolder] = useState(false);
const [dbInfo, setDbInfo] = useState(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState(null);
const [thumbnailInfo, setThumbnailInfo] = useState(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null);
const thresholdErrorTimerRef = useRef | null>(null);
const batchSizeErrorTimerRef = useRef | null>(null);
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
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 loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
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);
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
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);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
useEffect(() => {
if (!settingsOpen) return;
void loadTaggerModelStatus();
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
void loadTaggingQueueScope();
void loadTaggingQueueFolderIds();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => {
if (!settingsOpen || activeSection !== "general") return;
setVacuumResult(null);
setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {});
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
}, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]);
// Clean up error timers on unmount
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
};
}, []);
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
if (!settingsOpen) return null;
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 (
setSettingsOpen(false)}>
event.stopPropagation()}
>
Settings
Operational controls for AI workflows
{SECTIONS.map((section) => (
setActiveSection(section.id)}
>
{section.label}
{section.detail}
))}
{activeSection === "workspace" ? "AI Workspace" : "General"}
{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}
setSettingsOpen(false)}
title="Close settings"
>
{activeSection === "workspace" ? (
WD SwinV2 Tagger v3
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.
void prepareTaggerModel()}
disabled={taggerModelPreparing || taggerReady}
>
{taggerModelProgress ? : null}
{taggerDownloadLabel}
{taggerReady ? (
<>
void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
>
) : null}
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
{
setTaggerAccelerationSaving(true);
setTaggerAccelerationError(null);
void setTaggerAcceleration(nextAcceleration)
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
))}
{taggerAccelerationError ? (
{taggerAccelerationError}
) : (
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
)}
setTaggerThresholdDraft(event.target.value)}
onBlur={() => {
const value = parseFloat(thresholdDisplay);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null);
setTaggerThresholdSaving(true);
void setTaggerThreshold(value)
.catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
} else {
setTaggerThresholdDraft(null);
setTaggerThresholdError("Must be 0.05 – 0.99");
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
}
}}
/>
{taggerThresholdError ? (
{taggerThresholdError}
) : (
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
)}
setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null);
setTaggerBatchSizeSaving(true);
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeSaving(false);
});
} else {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeError("Must be 1 – 100");
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
}
}}
/>
{taggerBatchSizeError ? (
{taggerBatchSizeError}
) : (
{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}
)}
Model location
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
{taggerModelProgress?.current_file ?
{taggerModelProgress.current_file}
: null}
{taggerModelError ?
{taggerModelError}
: null}
{taggerRuntimeProbe ? (
Tagger acceleration: {taggerRuntimeProbe.acceleration}
{taggerRuntimeProbe.session.file}
) : null}
All media
Selected folders
Folder selection
Current target: {queueScopeLabel}.
setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0}
>
Select all
setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
>
Clear
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
return (
toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"}
>
{folder.name}
{folder.image_count.toLocaleString()} items
{(progress?.tagging_pending ?? 0) > 0 ? {progress?.tagging_pending} queued : null}
);
})}
{folders.length === 0 ?
Add a folder first to enable targeted tagging queues.
: null}
runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
{taggerQueueStatus ? {taggerQueueStatus}
: null}
) : (
Open the app data folder in Explorer to inspect or back up files.
{
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
}}
disabled={openingDataFolder}
>
{openingDataFolder ? "Opening..." : "Open data folder"}
Pause all notifications
Suppress all indexing notifications until re-enabled.
setNotificationsPaused(!notificationsPaused)}
>
Database size
{vacuumResult
? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"}
Reclaimable
{vacuumResult
? `−${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"}
{vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."}
{
setVacuuming(true);
setVacuumResult(null);
void vacuumDatabase()
.then((result) => {
setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
})
.catch(() => {})
.finally(() => setVacuuming(false));
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)}
>
{vacuuming ? "Compacting..." : "Compact now"}
Orphaned files
{cleaningThumbnails
? "—"
: thumbnailCleanupResult
? "0"
: thumbnailInfo
? thumbnailInfo.count.toLocaleString()
: "—"}
Reclaimable
{cleaningThumbnails
? "—"
: thumbnailCleanupResult
? "0 MB"
: thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"}
{cleaningThumbnails
? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult
? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} file${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}, freed ${thumbnailCleanupResult.freed_mb.toFixed(1)} MB.`
: thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000
? "Remove thumbnails no longer associated with any indexed image. This may take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."}
{
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
.then((result) => {
setThumbnailCleanupResult(result);
setThumbnailInfo(null);
})
.catch(() => {})
.finally(() => setCleaningThumbnails(false));
}}
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
>
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
)}
);
}