feat(settings): flat desktop redesign with proportional sizing

Complete presentation rework of the Settings modal away from the
card-based tablet look:

- Dialog now sizes proportionally (85vw x 85vh, capped at 1400x900)
  and resizes with the app window instead of a fixed centred square.
- Content rebuilt as flat form rows in the VS Code preferences style:
  uppercase group headings with hairline-divided label/control rows —
  no nested cards, panels, or boxed strips. Sidebar retained.
- Duplicated content header removed; close button floats top-right.
- Folder selection restyled from bordered chips to flat divider rows;
  model path and runtime check render as plain monospace text.

Also fixes two maintenance-section bugs: thumbnail-cache stats no
longer blank to em-dashes while a cleanup is running, and the Compact
now button disables immediately after compaction instead of allowing
pointless re-runs until the section remounted.
This commit is contained in:
2026-06-12 12:07:52 +01:00
parent b1290268a7
commit cd7dd89f00
+413 -428
View File
@@ -16,46 +16,56 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
? "border-sky-400/25 bg-sky-500/10 text-sky-300" ? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "border-white/10 bg-white/[0.04] text-gray-500"; : "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>; return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
} }
function SectionShell({ eyebrow, title, description, children }: { function SettingsGroup({ title, description, children }: {
eyebrow: string;
title: string; title: string;
description?: string; description?: string;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<section> <section>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-600">{eyebrow}</p> <h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
<h3 className="mt-1 text-lg font-semibold text-white">{title}</h3> {description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
{description ? <p className="mt-2 max-w-2xl text-sm leading-relaxed text-gray-500">{description}</p> : null} <div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
<div className="mt-5 space-y-4">{children}</div>
</section> </section>
); );
} }
function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { function SettingsItem({ label, description, children, vertical = false }: {
return ( label: React.ReactNode;
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5"> description?: React.ReactNode;
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4"> children?: React.ReactNode;
<p className="text-sm font-medium text-white">{title}</p> vertical?: boolean;
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : null} }) {
if (vertical) {
return (
<div className="py-4">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
{children ? <div className="mt-3">{children}</div> : null}
</div> </div>
<div className="pt-4">{children}</div> );
}
return (
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
</div>
<div className="shrink-0">{children}</div>
</div> </div>
); );
} }
function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return ( 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"> <span className="inline-flex items-baseline gap-2">
<div className="min-w-0"> <span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<p className="text-sm font-medium text-white">{title}</p> <span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p> </span>
</div>
<div className="shrink-0">{children}</div>
</div>
); );
} }
@@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: {
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "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" : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`} }`}
onClick={() => onSelect(scope)} onClick={() => onSelect(scope)}
> >
@@ -94,7 +104,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "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" : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`} }`}
onClick={() => onSelect(acceleration)} onClick={() => onSelect(acceleration)}
> >
@@ -277,7 +287,7 @@ export function SettingsModal() {
return ( 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 <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" className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<aside className="flex w-64 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]">
@@ -301,417 +311,391 @@ export function SettingsModal() {
</div> </div>
</aside> </aside>
<main className="flex min-w-0 flex-1 flex-col"> <button
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6"> className="absolute right-4 top-4 z-10 rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
<div> onClick={() => setSettingsOpen(false)}
<p className="text-sm font-medium text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</p> title="Close settings"
<p className="text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p> >
</div> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<button <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" </svg>
onClick={() => setSettingsOpen(false)} </button>
title="Close settings"
> <main className="min-w-0 flex-1 overflow-y-auto">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div className="px-10 py-8">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
</svg> <p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
</button>
</div>
<div className="flex-1 overflow-y-auto px-7 py-6">
{activeSection === "workspace" ? ( {activeSection === "workspace" ? (
<div className="space-y-8"> <div className="mt-8 space-y-9">
<SectionShell <SettingsGroup title="Model">
eyebrow="AI Workspace" <SettingsItem
title="Tagging" label={
> <>
<SettingsCard title="Tagging Models"> WD SwinV2 Tagger v3{" "}
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="ml-1.5 align-middle">
<div className="flex items-start justify-between gap-4"> <StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
<div> {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
<div className="flex items-center gap-2"> </StatusPill>
<p className="text-sm font-medium text-white">WD SwinV2 Tagger v3</p> </span>
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}> </>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} }
</StatusPill> description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
</div> >
<p className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500"> <div className="flex items-center gap-2">
Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds. {taggerReady ? (
</p> <>
</div>
<div className="flex flex-col items-end gap-2">
<button <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" 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 prepareTaggerModel()} onClick={() => void probeTaggerRuntime()}
disabled={taggerModelPreparing || taggerReady} disabled={taggerRuntimeChecking}
> >
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null} {taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
<span className="relative">{taggerDownloadLabel}</span>
</button> </button>
{taggerReady ? ( <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"
<button onClick={() => void deleteTaggerModel()}
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" disabled={taggerModelPreparing}
onClick={() => void probeTaggerRuntime()} >
disabled={taggerRuntimeChecking} Delete model files
> </button>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"} </>
</button> ) : (
<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" 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 deleteTaggerModel()} onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
Delete model files {taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
</button> <span className="relative">{taggerDownloadLabel}</span>
</> </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> </div>
</SettingsCard> </SettingsItem>
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs."> <SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<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 flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1"> <div className="flex rounded-lg border border-white/[0.07] p-0.5">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton> {(["auto", "directml", "cpu"] as const).map((acceleration) => (
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton> <TaggerAccelerationButton
</div> key={acceleration}
</SettingsRow> acceleration={acceleration}
current={taggerAcceleration}
<div className="border-b border-white/[0.07] py-4"> onSelect={(nextAcceleration) => {
<div className="flex items-center justify-between gap-4"> setTaggerAccelerationSaving(true);
<div> setTaggerAccelerationError(null);
<p className="text-sm font-medium text-white">Folder selection</p> void setTaggerAcceleration(nextAcceleration)
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p> .catch((error: unknown) => setTaggerAccelerationError(String(error)))
</div> .finally(() => setTaggerAccelerationSaving(false));
<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 {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
</button> </TaggerAccelerationButton>
<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>
{taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
)}
</div>
</SettingsItem>
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}> <SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
{folders.map((folder) => { <div className="flex flex-col items-end gap-1.5">
const active = taggingQueueFolderIds.includes(folder.id); <input
const progress = mediaJobProgress[folder.id]; type="number"
return ( min="0.05"
<button max="0.99"
key={folder.id} step="0.05"
type="button" 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"
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${ value={thresholdDisplay}
active onChange={(event) => setTaggerThresholdDraft(event.target.value)}
? "border-emerald-400/30 bg-emerald-500/10 text-white" onBlur={() => {
: "border-white/[0.07] bg-black/20 text-gray-300 hover:border-white/15 hover:bg-white/[0.04]" const value = parseFloat(thresholdDisplay);
}`} if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
onClick={() => toggleTaggingQueueFolder(folder.id)} setTaggerThresholdError(null);
disabled={taggingQueueScope === "all"} setTaggerThresholdSaving(true);
> void setTaggerThreshold(value)
<div> .catch((error: unknown) => setTaggerThresholdError(String(error)))
<p className="text-sm font-medium">{folder.name}</p> .finally(() => {
<p className="mt-1 text-[11px] text-gray-500">{folder.image_count.toLocaleString()} items</p> setTaggerThresholdDraft(null);
</div> setTaggerThresholdSaving(false);
<div className="flex items-center gap-2"> });
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null} } else {
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} /> setTaggerThresholdDraft(null);
</div> setTaggerThresholdError("Must be 0.05 0.99");
</button> if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
); thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
})} }
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null} }}
/>
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-1.5">
<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>
</SettingsItem>
<SettingsItem label="Model location" vertical>
<div>
<p className="break-all font-mono text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? (
<div className="mt-3">
<p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
</p>
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsItem label="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] p-0.5">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
</SettingsItem>
<div className="py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm 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> </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={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
<div className="flex flex-col items-end gap-2"> {folders.map((folder) => {
<button const active = taggingQueueFolderIds.includes(folder.id);
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" const progress = mediaJobProgress[folder.id];
onClick={() => runQueueAction("queue")} return (
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} <button
> key={folder.id}
{taggerQueueing ? "Queueing..." : "Queue tagging"} type="button"
</button> className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
<button active ? "text-white" : "text-gray-400 hover:text-gray-200"
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")} onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={taggingQueueScope === "all"}
> >
{taggerClearing ? "Clearing..." : "Clear queued jobs"} <p className="min-w-0 truncate text-sm">{folder.name}</p>
</button> <div className="flex shrink-0 items-center gap-3">
</div> {(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
</SettingsRow> <span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
<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="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div>
</div>
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null} <SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
</SettingsCard> <div className="flex items-center gap-2">
</SectionShell> <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>
</SettingsItem>
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsGroup>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="mt-8 space-y-9">
<SectionShell <SettingsGroup title="Storage & notifications">
eyebrow="General" <SettingsItem
title="App data" label="App data folder"
description="Access the folder where Phokus stores its database, thumbnails, AI models, and settings." description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
> >
<SettingsCard title="Storage location"> <button
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4"> 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"
<p className="text-sm text-gray-400">Open the app data folder in Explorer to inspect or back up files.</p> onClick={() => {
<button setOpeningDataFolder(true);
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" void openAppDataFolder().finally(() => setOpeningDataFolder(false));
onClick={() => { }}
setOpeningDataFolder(true); disabled={openingDataFolder}
void openAppDataFolder().finally(() => setOpeningDataFolder(false)); >
}} {openingDataFolder ? "Opening..." : "Open data folder"}
disabled={openingDataFolder} </button>
> </SettingsItem>
{openingDataFolder ? "Opening..." : "Open data folder"} <SettingsItem
</button> label="Pause all notifications"
</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." 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"> <button
<div> role="switch"
<p className="text-sm font-medium text-white">Pause all notifications</p> aria-checked={notificationsPaused}
<p className="mt-0.5 text-xs text-gray-500">Suppress all indexing notifications until re-enabled.</p> 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"}`}
</div> onClick={() => setNotificationsPaused(!notificationsPaused)}
<button >
role="switch" <span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
aria-checked={notificationsPaused} </button>
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"}`} </SettingsItem>
onClick={() => setNotificationsPaused(!notificationsPaused)} </SettingsGroup>
>
<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 <SettingsGroup title="Maintenance">
title="Compact database" <SettingsItem
description="Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time." label="Compact database"
> description={
<div className="space-y-3"> <>
<div className="grid grid-cols-2 gap-3"> <span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Database size</p> <StatPair
<p className="mt-2 text-2xl font-semibold text-white"> label="Size"
{vacuumResult value={
? `${vacuumResult.after_mb.toFixed(1)} MB` vacuumResult
: dbInfo ? `${vacuumResult.after_mb.toFixed(1)} MB`
? `${dbInfo.size_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> <StatPair
<p className={`mt-2 text-2xl font-semibold ${vacuumResult ? "text-emerald-300" : "text-white"}`}> label="Reclaimable"
{vacuumResult accent={vacuumResult !== null}
? `${vacuumResult.freed_mb.toFixed(1)} MB freed` value={
: dbInfo vacuumResult
? `${dbInfo.reclaimable_mb.toFixed(1)} MB` ? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: "—"} : dbInfo
</p> ? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
</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"> </span>
<span className="mt-2 block text-gray-600">
{vacuumResult {vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.` ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5 : dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact." ? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."} : "Run this after removing folders or bulk-deleting images."}
</p> </span>
<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"> <button
<div className="grid grid-cols-2 gap-3"> 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"
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> onClick={() => {
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Orphaned files</p> setVacuuming(true);
<p className="mt-2 text-2xl font-semibold text-white"> setVacuumResult(null);
{cleaningThumbnails void vacuumDatabase()
? "—" .then((result) => {
: thumbnailCleanupResult setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
})
.catch(() => {})
.finally(() => setVacuuming(false));
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
>
{vacuuming ? "Compacting..." : "Compact now"}
</button>
</SettingsItem>
<SettingsItem
label="Thumbnail cache"
description={
<>
<span>Thumbnails left behind when folders or images are removed. Safe to delete they are regenerated if the originals are re-indexed.</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Orphaned files"
value={
thumbnailCleanupResult
? "0" ? "0"
: thumbnailInfo : thumbnailInfo
? thumbnailInfo.count.toLocaleString() ? thumbnailInfo.count.toLocaleString()
: "—"} : "—"
</p> }
</div> />
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4"> <StatPair
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p> label="Reclaimable"
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}> accent={thumbnailCleanupResult !== null}
{cleaningThumbnails value={
? "—" thumbnailCleanupResult
: thumbnailCleanupResult ? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
? "0 MB"
: thumbnailInfo : thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB` ? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"} : "—"
</p> }
</div> />
</div> </span>
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4"> <span className="mt-2 block text-gray-600">
<p className="text-sm text-gray-400">
{cleaningThumbnails {cleaningThumbnails
? "Scanning and removing orphaned thumbnails…" ? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult : thumbnailCleanupResult
@@ -719,29 +703,30 @@ export function SettingsModal() {
: thumbnailInfo && thumbnailInfo.count === 0 : thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found." ? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000 : thumbnailInfo && thumbnailInfo.count > 1000
? "Remove thumbnails no longer associated with any indexed image. This may take a few minutes for large collections." ? "May take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."} : "Remove thumbnails no longer associated with any indexed image."}
</p> </span>
<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); <button
cleanupOrphanedThumbnails() 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"
.then((result) => { onClick={() => {
setThumbnailCleanupResult(result); setCleaningThumbnails(true);
setThumbnailInfo(null); cleanupOrphanedThumbnails()
}) .then((result) => {
.catch(() => {}) setThumbnailCleanupResult(result);
.finally(() => setCleaningThumbnails(false)); setThumbnailInfo(null);
}} })
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)} .catch(() => {})
> .finally(() => setCleaningThumbnails(false));
{cleaningThumbnails ? "Cleaning…" : "Clean up"} }}
</button> disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
</div> >
</div> {cleaningThumbnails ? "Cleaning…" : "Clean up"}
</SettingsCard> </button>
</SectionShell> </SettingsItem>
</SettingsGroup>
</div> </div>
)} )}
</div> </div>