a34d38d9d3
Debounce embedding/tagging completion notifications per folder (6s window) so rapid file additions from downloaders fire one notification instead of one per file. Add per-folder mute via the sidebar context menu and a global pause toggle in Settings > General, both persisted across restarts.
753 lines
43 KiB
TypeScript
753 lines
43 KiB
TypeScript
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 <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 (
|
||
<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 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;
|
||
children: React.ReactNode;
|
||
}) {
|
||
const active = acceleration === 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(acceleration)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
export function SettingsModal() {
|
||
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 [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
|
||
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
|
||
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
|
||
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
|
||
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
|
||
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
|
||
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null);
|
||
const [openingDataFolder, setOpeningDataFolder] = useState(false);
|
||
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
|
||
const [vacuuming, setVacuuming] = useState(false);
|
||
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
|
||
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
|
||
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
|
||
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
|
||
|
||
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
|
||
<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(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-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">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"
|
||
}`}
|
||
onClick={() => setActiveSection(section.id)}
|
||
>
|
||
<span className="block text-[13px] font-medium">{section.label}</span>
|
||
<span className="mt-0.5 block text-[11px] text-gray-600">{section.detail}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
|
||
<main className="flex min-w-0 flex-1 flex-col">
|
||
<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" : "General"}</p>
|
||
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</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)}
|
||
title="Close settings"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||
{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>
|
||
|
||
<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);
|
||
setTaggerAccelerationError(null);
|
||
void setTaggerAcceleration(nextAcceleration)
|
||
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
|
||
.finally(() => setTaggerAccelerationSaving(false));
|
||
}}
|
||
>
|
||
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
|
||
</TaggerAccelerationButton>
|
||
))}
|
||
</div>
|
||
{taggerAccelerationError ? (
|
||
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
|
||
) : (
|
||
<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) {
|
||
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 ? (
|
||
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
|
||
) : (
|
||
<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) {
|
||
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 ? (
|
||
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
|
||
) : (
|
||
<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 disabled:cursor-not-allowed"
|
||
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
|
||
disabled={taggingQueueScope === "all" || 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 disabled:cursor-not-allowed"
|
||
onClick={() => setTaggingQueueFolderIds([])}
|
||
disabled={taggingQueueScope === "all" || 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 disabled:cursor-not-allowed ${
|
||
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)}
|
||
disabled={taggingQueueScope === "all"}
|
||
>
|
||
<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>
|
||
</SectionShell>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-8">
|
||
<SectionShell
|
||
eyebrow="General"
|
||
title="App data"
|
||
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings."
|
||
>
|
||
<SettingsCard title="Storage location">
|
||
<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">Open the app data folder in Explorer to inspect or back up files.</p>
|
||
<button
|
||
className="shrink-0 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={() => {
|
||
setOpeningDataFolder(true);
|
||
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
|
||
}}
|
||
disabled={openingDataFolder}
|
||
>
|
||
{openingDataFolder ? "Opening..." : "Open data folder"}
|
||
</button>
|
||
</div>
|
||
</SettingsCard>
|
||
|
||
<SettingsCard
|
||
title="Notifications"
|
||
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
|
||
>
|
||
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||
<div>
|
||
<p className="text-sm font-medium text-white">Pause all notifications</p>
|
||
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p>
|
||
</div>
|
||
<button
|
||
role="switch"
|
||
aria-checked={notificationsPaused}
|
||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
|
||
onClick={() => setNotificationsPaused(!notificationsPaused)}
|
||
>
|
||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||
</button>
|
||
</div>
|
||
</SettingsCard>
|
||
|
||
<SettingsCard
|
||
title="Compact database"
|
||
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time."
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-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">Database size</p>
|
||
<p className="mt-2 text-2xl font-semibold text-white">
|
||
{vacuumResult
|
||
? `${vacuumResult.after_mb.toFixed(1)} MB`
|
||
: dbInfo
|
||
? `${dbInfo.size_mb.toFixed(1)} MB`
|
||
: "—"}
|
||
</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">Reclaimable</p>
|
||
<p className={`mt-2 text-2xl font-semibold ${vacuumResult ? "text-emerald-300" : "text-white"}`}>
|
||
{vacuumResult
|
||
? `−${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
||
: dbInfo
|
||
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
|
||
: "—"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<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">
|
||
{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."}
|
||
</p>
|
||
<button
|
||
className="shrink-0 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={() => {
|
||
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"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</SettingsCard>
|
||
|
||
<SettingsCard
|
||
title="Thumbnail cache"
|
||
description="Thumbnails left behind when folders or images are removed. Safe to delete — they will be regenerated if the original files are re-indexed."
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-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">Orphaned files</p>
|
||
<p className="mt-2 text-2xl font-semibold text-white">
|
||
{cleaningThumbnails
|
||
? "—"
|
||
: thumbnailCleanupResult
|
||
? "0"
|
||
: thumbnailInfo
|
||
? thumbnailInfo.count.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">Reclaimable</p>
|
||
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}>
|
||
{cleaningThumbnails
|
||
? "—"
|
||
: thumbnailCleanupResult
|
||
? "0 MB"
|
||
: thumbnailInfo
|
||
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
|
||
: "—"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<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">
|
||
{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."}
|
||
</p>
|
||
<button
|
||
className="shrink-0 rounded-lg bg-white/[0.07] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/[0.11] disabled:cursor-not-allowed disabled:opacity-40"
|
||
onClick={() => {
|
||
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"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</SettingsCard>
|
||
</SectionShell>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|