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:
+178
-193
@@ -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 }: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
vertical?: boolean;
|
||||||
|
}) {
|
||||||
|
if (vertical) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.03] p-5">
|
<div className="py-4">
|
||||||
<div className="flex flex-col gap-1 border-b border-white/[0.07] pb-4">
|
<p className="text-sm text-white">{label}</p>
|
||||||
<p className="text-sm font-medium text-white">{title}</p>
|
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
|
||||||
{description ? <p className="text-xs leading-relaxed text-gray-500">{description}</p> : 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,14 +311,8 @@ export function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</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
|
<button
|
||||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
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"
|
||||||
onClick={() => setSettingsOpen(false)}
|
onClick={() => setSettingsOpen(false)}
|
||||||
title="Close settings"
|
title="Close settings"
|
||||||
>
|
>
|
||||||
@@ -316,38 +320,29 @@ export function SettingsModal() {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
<main className="min-w-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="px-10 py-8">
|
||||||
|
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
|
||||||
|
|
||||||
{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">
|
|
||||||
<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"}>
|
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
|
||||||
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</div>
|
</span>
|
||||||
<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>
|
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
|
||||||
</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}
|
<div className="flex items-center gap-2">
|
||||||
<span className="relative">{taggerDownloadLabel}</span>
|
|
||||||
</button>
|
|
||||||
{taggerReady ? (
|
{taggerReady ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -365,14 +360,22 @@ export function SettingsModal() {
|
|||||||
Delete model files
|
Delete model files
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<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}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SettingsItem>
|
||||||
|
|
||||||
<div className="mt-4 space-y-4 border-t border-white/[0.07] pt-4">
|
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
||||||
<SettingsRow title="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
|
||||||
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
|
||||||
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
|
||||||
<TaggerAccelerationButton
|
<TaggerAccelerationButton
|
||||||
key={acceleration}
|
key={acceleration}
|
||||||
@@ -396,10 +399,10 @@ export function SettingsModal() {
|
|||||||
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsRow title="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0.05"
|
min="0.05"
|
||||||
@@ -433,10 +436,10 @@ export function SettingsModal() {
|
|||||||
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsItem>
|
||||||
|
|
||||||
<SettingsRow title="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@@ -470,42 +473,40 @@ export function SettingsModal() {
|
|||||||
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsItem>
|
||||||
|
|
||||||
|
<SettingsItem label="Model location" vertical>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-gray-400">Model location</p>
|
<p className="break-all font-mono text-xs text-gray-600">
|
||||||
<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"}
|
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
|
||||||
</p>
|
</p>
|
||||||
{taggerModelProgress?.current_file ? <p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
|
{taggerModelProgress?.current_file ? <p className="mt-2 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}
|
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
|
||||||
{taggerRuntimeProbe ? (
|
{taggerRuntimeProbe ? (
|
||||||
<div className="mt-4 rounded-lg border border-white/[0.07] bg-black/20 px-3 py-3">
|
<div className="mt-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<p className="text-xs text-gray-400">
|
||||||
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
|
||||||
<StatusPill tone="ready">Ready</StatusPill>
|
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
|
||||||
</div>
|
</p>
|
||||||
<p className="mt-2 text-xs text-gray-600">Tagger acceleration: {taggerRuntimeProbe.acceleration}</p>
|
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
|
||||||
<p className="mt-2 break-all text-xs text-gray-500">{taggerRuntimeProbe.session.file}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingsItem>
|
||||||
</div>
|
</SettingsGroup>
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard title="Queue Targets" description="Choose which folders to include when queuing tagging jobs.">
|
<SettingsGroup 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.">
|
<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] 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>
|
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
|
||||||
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
|
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsItem>
|
||||||
|
|
||||||
<div className="border-b border-white/[0.07] py-4">
|
<div className="py-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-white">Folder selection</p>
|
<p className="text-sm text-white">Folder selection</p>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
|
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -526,7 +527,7 @@ export function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`mt-4 grid max-h-64 gap-2 overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
<div className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
|
||||||
{folders.map((folder) => {
|
{folders.map((folder) => {
|
||||||
const active = taggingQueueFolderIds.includes(folder.id);
|
const active = taggingQueueFolderIds.includes(folder.id);
|
||||||
const progress = mediaJobProgress[folder.id];
|
const progress = mediaJobProgress[folder.id];
|
||||||
@@ -534,31 +535,27 @@ export function SettingsModal() {
|
|||||||
<button
|
<button
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex items-center justify-between rounded-xl border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
|
||||||
active
|
active ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||||
? "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)}
|
onClick={() => toggleTaggingQueueFolder(folder.id)}
|
||||||
disabled={taggingQueueScope === "all"}
|
disabled={taggingQueueScope === "all"}
|
||||||
>
|
>
|
||||||
<div>
|
<p className="min-w-0 truncate text-sm">{folder.name}</p>
|
||||||
<p className="text-sm font-medium">{folder.name}</p>
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
<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}
|
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
|
||||||
|
<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"}`} />
|
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{folders.length === 0 ? <p className="text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
|
{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>
|
||||||
</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.">
|
<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.">
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<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"
|
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")}
|
onClick={() => runQueueAction("queue")}
|
||||||
@@ -574,24 +571,20 @@ export function SettingsModal() {
|
|||||||
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsItem>
|
||||||
|
|
||||||
{taggerQueueStatus ? <p className="pt-4 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
|
||||||
</SettingsCard>
|
</SettingsGroup>
|
||||||
</SectionShell>
|
|
||||||
</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">
|
|
||||||
<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
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
setOpeningDataFolder(true);
|
setOpeningDataFolder(true);
|
||||||
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
|
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
|
||||||
@@ -600,18 +593,11 @@ export function SettingsModal() {
|
|||||||
>
|
>
|
||||||
{openingDataFolder ? "Opening..." : "Open data folder"}
|
{openingDataFolder ? "Opening..." : "Open data folder"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</SettingsItem>
|
||||||
</SettingsCard>
|
<SettingsItem
|
||||||
|
label="Pause all notifications"
|
||||||
<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">
|
|
||||||
<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
|
<button
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={notificationsPaused}
|
aria-checked={notificationsPaused}
|
||||||
@@ -620,46 +606,50 @@ export function SettingsModal() {
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</SettingsItem>
|
||||||
</SettingsCard>
|
</SettingsGroup>
|
||||||
|
|
||||||
<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
|
||||||
? `${vacuumResult.after_mb.toFixed(1)} MB`
|
? `${vacuumResult.after_mb.toFixed(1)} MB`
|
||||||
: dbInfo
|
: dbInfo
|
||||||
? `${dbInfo.size_mb.toFixed(1)} MB`
|
? `${dbInfo.size_mb.toFixed(1)} MB`
|
||||||
: "—"}
|
: "—"
|
||||||
</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 ${vacuumResult ? "text-emerald-300" : "text-white"}`}>
|
accent={vacuumResult !== null}
|
||||||
{vacuumResult
|
value={
|
||||||
? `−${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
vacuumResult
|
||||||
|
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
|
||||||
: dbInfo
|
: dbInfo
|
||||||
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
|
? `${dbInfo.reclaimable_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">
|
|
||||||
{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
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
setVacuuming(true);
|
setVacuuming(true);
|
||||||
setVacuumResult(null);
|
setVacuumResult(null);
|
||||||
@@ -671,47 +661,41 @@ export function SettingsModal() {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setVacuuming(false));
|
.finally(() => setVacuuming(false));
|
||||||
}}
|
}}
|
||||||
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5 && vacuumResult === null)}
|
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
|
||||||
>
|
>
|
||||||
{vacuuming ? "Compacting..." : "Compact now"}
|
{vacuuming ? "Compacting..." : "Compact now"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</SettingsItem>
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsItem
|
||||||
title="Thumbnail cache"
|
label="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."
|
description={
|
||||||
>
|
<>
|
||||||
<div className="space-y-3">
|
<span>Thumbnails left behind when folders or images are removed. Safe to delete — they are regenerated if the originals are re-indexed.</span>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
|
||||||
<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">Orphaned files</p>
|
label="Orphaned files"
|
||||||
<p className="mt-2 text-2xl font-semibold text-white">
|
value={
|
||||||
{cleaningThumbnails
|
thumbnailCleanupResult
|
||||||
? "—"
|
|
||||||
: 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,11 +703,14 @@ 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
|
<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"
|
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={() => {
|
onClick={() => {
|
||||||
setCleaningThumbnails(true);
|
setCleaningThumbnails(true);
|
||||||
cleanupOrphanedThumbnails()
|
cleanupOrphanedThumbnails()
|
||||||
@@ -738,10 +725,8 @@ export function SettingsModal() {
|
|||||||
>
|
>
|
||||||
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
|
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</SettingsItem>
|
||||||
</div>
|
</SettingsGroup>
|
||||||
</SettingsCard>
|
|
||||||
</SectionShell>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user