diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index 4a67201..5b6be57 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -1,257 +1,42 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
-import { FfmpegStatusRow } from "./onboarding/StepWelcome";
-import { Dropdown } from "./menu";
-import { getChangelogForVersion } from "../changelog";
+import { useEffect, useState } from "react";
+import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
-import { TAGGER_MODELS } from "../taggerModels";
import { CloseIcon } from "./icons";
+import { AiWorkspaceSettingsSection } from "./settings/AiWorkspaceSettingsSection";
+import { GeneralSettingsSection } from "./settings/GeneralSettingsSection";
+import { MediaSettingsSection } from "./settings/MediaSettingsSection";
+import { StorageSettingsSection } from "./settings/StorageSettingsSection";
+import { UpdatesSettingsSection } from "./settings/UpdatesSettingsSection";
+import { SETTINGS_SECTIONS, SettingsSection } from "./settings/shared";
-type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace";
-
-const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
- { id: "general", label: "General", detail: "Theme and notifications" },
- { id: "media", label: "Media", detail: "Playback and slideshow" },
- { id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" },
- { id: "storage", label: "Storage", detail: "App data and maintenance" },
- { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
-];
-
-function formatBytesShort(bytes: number): string {
- if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
- if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
- return `${(bytes / 1024).toFixed(0)} KB`;
-}
-
-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 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
- : tone === "busy"
- ? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"
- : "border-white/10 bg-white/[0.04] text-gray-500";
-
- return {children} ;
-}
-
-function SettingsGroup({ title, description, children }: {
- title: string;
- description?: string;
- children: React.ReactNode;
-}) {
- return (
-
- {title}
- {description ? {description}
: null}
- {children}
-
- );
-}
-
-function SettingsItem({ label, description, children, vertical = false }: {
- label: React.ReactNode;
- description?: React.ReactNode;
- children?: React.ReactNode;
- vertical?: boolean;
-}) {
- if (vertical) {
- return (
-
-
{label}
- {description ?
{description}
: null}
- {children ?
{children}
: null}
-
- );
+function ActiveSettingsSection({ section }: { section: SettingsSection }) {
+ switch (section) {
+ case "workspace":
+ return ;
+ case "media":
+ return ;
+ case "updates":
+ return ;
+ case "storage":
+ return ;
+ case "general":
+ default:
+ return ;
}
-
- return (
-
-
-
{label}
- {description ?
{description}
: null}
-
-
{children}
-
- );
-}
-
-function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function ScopeButton({ scope, current, onSelect, children }: {
- scope: TaggingQueueScope;
- current: TaggingQueueScope;
- onSelect: (scope: TaggingQueueScope) => void;
- children: React.ReactNode;
-}) {
- const active = scope === current;
- return (
- onSelect(scope)}
- >
- {children}
-
- );
-}
-
-function TaggerModelButton({ model, current, onSelect, children }: {
- model: TaggerModel;
- current: TaggerModel;
- onSelect: (model: TaggerModel) => void;
- children: React.ReactNode;
-}) {
- const active = model === current;
- return (
- onSelect(model)}
- >
- {children}
-
- );
-}
-
-function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
- acceleration: TaggerAcceleration;
- current: TaggerAcceleration;
- onSelect: (acceleration: TaggerAcceleration) => void;
- children: React.ReactNode;
-}) {
- const active = acceleration === current;
- return (
- onSelect(acceleration)}
- >
- {children}
-
- );
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState("general");
- const [taggerQueueStatus, setTaggerQueueStatus] = useState(null);
- const [taggerQueueing, setTaggerQueueing] = useState(false);
- const [taggerClearing, setTaggerClearing] = useState(false);
- const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
- const [taggerResetting, setTaggerResetting] = useState(false);
- const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
- const [taggerAccelerationError, setTaggerAccelerationError] = useState(null);
- const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
- const [taggerModelSwitchError, setTaggerModelSwitchError] = useState(null);
- const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null);
- const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
- const [taggerThresholdError, setTaggerThresholdError] = useState(null);
- const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null);
- const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
- const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null);
- const [openingDataFolder, setOpeningDataFolder] = useState(false);
- const [dbInfo, setDbInfo] = useState(null);
- const [vacuuming, setVacuuming] = useState(false);
- const [vacuumResult, setVacuumResult] = useState(null);
- const [rebuildingIndex, setRebuildingIndex] = useState(false);
- const [rebuildIndexResult, setRebuildIndexResult] = useState(null);
- const [thumbnailInfo, setThumbnailInfo] = useState(null);
- const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
- const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null);
-
- const thresholdErrorTimerRef = useRef | null>(null);
- const batchSizeErrorTimerRef = useRef | null>(null);
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
- const folders = useGalleryStore((state) => state.folders);
- const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
- const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
- const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
- const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
- const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
- const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
- const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
- const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
- const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
- const taggerModelError = useGalleryStore((state) => state.taggerModelError);
- const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
- const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
- const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
- const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
- const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
- const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
- const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
- const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
- const taggerModel = useGalleryStore((state) => state.taggerModel);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
- const setTaggerModel = useGalleryStore((state) => state.setTaggerModel);
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 resetAiTags = useGalleryStore((state) => state.resetAiTags);
- const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders);
- const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
- const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
- const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
- const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist);
- const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist);
- const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
- const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
- const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
- const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
- const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
- const appVersion = useGalleryStore((state) => state.appVersion);
- const buildVariant = useGalleryStore((state) => state.buildVariant);
- const updateStatus = useGalleryStore((state) => state.updateStatus);
- const updateVersion = useGalleryStore((state) => state.updateVersion);
- const updateProgress = useGalleryStore((state) => state.updateProgress);
- const updateError = useGalleryStore((state) => state.updateError);
- const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
- const installUpdate = useGalleryStore((state) => state.installUpdate);
- const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
- const openOnboarding = useGalleryStore((state) => state.openOnboarding);
- const theme = useGalleryStore((state) => state.theme);
- const setTheme = useGalleryStore((state) => state.setTheme);
- const openTagManager = useGalleryStore((state) => state.openTagManager);
- const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
- const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
- const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
- const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
- const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
- const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
- const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
- const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder);
- const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
- const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition);
useEffect(() => {
if (!settingsOpen) return;
@@ -268,145 +53,21 @@ export function SettingsModal() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
- }, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
-
- useEffect(() => {
- if (!settingsOpen || activeSection !== "storage") 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],
- );
+ }, [
+ settingsOpen,
+ loadTaggerModelStatus,
+ loadTaggerModel,
+ loadTaggerAcceleration,
+ loadTaggerThreshold,
+ loadTaggerBatchSize,
+ loadTaggingQueueScope,
+ loadTaggingQueueFolderIds,
+ setSettingsOpen,
+ ]);
if (!settingsOpen) return null;
- const activeSectionMeta = SECTIONS.find((section) => section.id === activeSection) ?? SECTIONS[0];
- 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 taggerBytesKnown =
- taggerModelProgress?.downloaded_bytes != null &&
- taggerModelProgress.total_bytes != null &&
- taggerModelProgress.total_bytes > 0;
- const taggerDownloadLabel = taggerBytesKnown
- ? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
- : taggerModelProgress
- ? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
- : taggerModelPreparing
- ? "Preparing AI tagger..."
- : taggerReady
- ? "Installed"
- : "Install model";
- const taggerDownloadPercent = taggerBytesKnown
- ? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
- : 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);
- setTaggerResetConfirming(false);
-
- 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);
- }
- });
- };
-
- const runResetAiTags = () => {
- if (!taggerResetConfirming) {
- setTaggerResetConfirming(true);
- setTaggerQueueStatus(
- `Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`,
- );
- return;
- }
-
- const selectedIds = taggingQueueFolderIds;
- const perform =
- taggingQueueScope === "all"
- ? resetAiTags(null)
- : selectedIds.length > 0
- ? resetAiTagsForFolders(selectedIds)
- : Promise.resolve(0);
-
- setTaggerResetting(true);
- setTaggerQueueStatus(null);
-
- void perform
- .then((count) => {
- if (taggingQueueScope === "selected" && selectedIds.length === 0) {
- setTaggerQueueStatus("Choose at least one folder before resetting AI tags.");
- return;
- }
- setTaggerQueueStatus(
- count === 0
- ? "No AI tag data found for the current target."
- : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
- );
- })
- .catch((error) => setTaggerQueueStatus(String(error)))
- .finally(() => {
- setTaggerResetting(false);
- setTaggerResetConfirming(false);
- });
- };
+ const activeSectionMeta = SETTINGS_SECTIONS.find((section) => section.id === activeSection) ?? SETTINGS_SECTIONS[0];
return (
setSettingsOpen(false)}>
@@ -417,10 +78,9 @@ export function SettingsModal() {
diff --git a/src/components/settings/AiWorkspaceSettingsSection.tsx b/src/components/settings/AiWorkspaceSettingsSection.tsx
new file mode 100644
index 0000000..b656b01
--- /dev/null
+++ b/src/components/settings/AiWorkspaceSettingsSection.tsx
@@ -0,0 +1,501 @@
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useGalleryStore } from "../../store";
+import { TAGGER_MODELS } from "../../taggerModels";
+import {
+ formatBytesShort,
+ ScopeButton,
+ SettingsGroup,
+ SettingsItem,
+ settingsButtonClass,
+ StatusPill,
+ TaggerAccelerationButton,
+ TaggerModelButton,
+} from "./shared";
+
+export function AiWorkspaceSettingsSection() {
+ const folders = useGalleryStore((state) => state.folders);
+ const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
+ const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
+ const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
+ const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
+ const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
+ const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
+ const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
+ const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
+ const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
+ const taggerModelError = useGalleryStore((state) => state.taggerModelError);
+ const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
+ const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
+ const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
+ const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
+ const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
+ const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
+ const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
+ const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
+ const taggerModel = useGalleryStore((state) => state.taggerModel);
+ const setTaggerModel = useGalleryStore((state) => state.setTaggerModel);
+ const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
+ 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 resetAiTags = useGalleryStore((state) => state.resetAiTags);
+ const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders);
+ const openTagManager = useGalleryStore((state) => state.openTagManager);
+
+ const [taggerQueueStatus, setTaggerQueueStatus] = useState(null);
+ const [taggerQueueing, setTaggerQueueing] = useState(false);
+ const [taggerClearing, setTaggerClearing] = useState(false);
+ const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
+ const [taggerResetting, setTaggerResetting] = useState(false);
+ const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
+ const [taggerAccelerationError, setTaggerAccelerationError] = useState(null);
+ const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
+ const [taggerModelSwitchError, setTaggerModelSwitchError] = useState(null);
+ const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null);
+ const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
+ const [taggerThresholdError, setTaggerThresholdError] = useState(null);
+ const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null);
+ const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
+ const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null);
+
+ const thresholdErrorTimerRef = useRef | null>(null);
+ const batchSizeErrorTimerRef = useRef | null>(null);
+
+ 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],
+ );
+
+ 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 taggerBytesKnown =
+ taggerModelProgress?.downloaded_bytes != null &&
+ taggerModelProgress.total_bytes != null &&
+ taggerModelProgress.total_bytes > 0;
+ const taggerDownloadLabel = taggerBytesKnown
+ ? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
+ : taggerModelProgress
+ ? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
+ : taggerModelPreparing
+ ? "Preparing AI tagger..."
+ : taggerReady
+ ? "Installed"
+ : "Install model";
+ const taggerDownloadPercent = taggerBytesKnown
+ ? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
+ : 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);
+ setTaggerResetConfirming(false);
+
+ 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);
+ }
+ });
+ };
+
+ const runResetAiTags = () => {
+ if (!taggerResetConfirming) {
+ setTaggerResetConfirming(true);
+ setTaggerQueueStatus(
+ `Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`,
+ );
+ return;
+ }
+
+ const selectedIds = taggingQueueFolderIds;
+ const perform =
+ taggingQueueScope === "all"
+ ? resetAiTags(null)
+ : selectedIds.length > 0
+ ? resetAiTagsForFolders(selectedIds)
+ : Promise.resolve(0);
+
+ setTaggerResetting(true);
+ setTaggerQueueStatus(null);
+
+ void perform
+ .then((count) => {
+ if (taggingQueueScope === "selected" && selectedIds.length === 0) {
+ setTaggerQueueStatus("Choose at least one folder before resetting AI tags.");
+ return;
+ }
+ setTaggerQueueStatus(
+ count === 0
+ ? "No AI tag data found for the current target."
+ : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
+ );
+ })
+ .catch((error) => setTaggerQueueStatus(String(error)))
+ .finally(() => {
+ setTaggerResetting(false);
+ setTaggerResetConfirming(false);
+ });
+ };
+
+ return (
+
+
+
+
+
+ {(["wd", "joytag"] as const).map((model) => (
+ {
+ if (nextModel === taggerModel) return;
+ setTaggerThresholdDraft(null);
+ setTaggerThresholdError(null);
+ if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
+ setTaggerModelSwitching(true);
+ setTaggerModelSwitchError(null);
+ void setTaggerModel(nextModel)
+ .catch((error: unknown) => setTaggerModelSwitchError(String(error)))
+ .finally(() => setTaggerModelSwitching(false));
+ }}
+ >
+ {TAGGER_MODELS[model].tab}
+
+ ))}
+
+ {taggerModelSwitchError ? (
+
{taggerModelSwitchError}
+ ) : (
+
{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}
+ )}
+
+
+
+
+ {TAGGER_MODELS[taggerModel].name}{" "}
+
+
+ {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
+
+
+ >
+ }
+ description={TAGGER_MODELS[taggerModel].description}
+ >
+
+
+ {taggerReady ? (
+ <>
+ void probeTaggerRuntime()} disabled={taggerRuntimeChecking}>
+ {taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
+
+ void deleteTaggerModel()}
+ disabled={taggerModelPreparing}
+ >
+ Delete model files
+
+ >
+ ) : (
+ void prepareTaggerModel()} disabled={taggerModelPreparing}>
+ {taggerModelProgress ? : null}
+ {taggerDownloadLabel}
+
+ )}
+
+ {taggerRuntimeProbe ? (
+
+
+ Runtime check Ready
+ {" "}· acceleration: {taggerRuntimeProbe.acceleration}
+
+
{taggerRuntimeProbe.session.file}
+
+ ) : null}
+
+
+
+
+
+
+ {(["auto", "directml", "cpu"] as const).map((acceleration) => (
+ {
+ setTaggerAccelerationSaving(true);
+ setTaggerAccelerationError(null);
+ void setTaggerAcceleration(nextAcceleration)
+ .catch((error: unknown) => setTaggerAccelerationError(String(error)))
+ .finally(() => setTaggerAccelerationSaving(false));
+ }}
+ >
+ {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
+
+ ))}
+
+ {taggerAccelerationError ? (
+
{taggerAccelerationError}
+ ) : (
+
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
+ )}
+
+
+
+
+
+
setTaggerThresholdDraft(event.target.value)}
+ onBlur={(event) => {
+ const value = parseFloat(event.currentTarget.value);
+ if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
+ setTaggerThresholdError(null);
+ setTaggerThresholdSaving(true);
+ void setTaggerThreshold(value, taggerModel)
+ .catch((error: unknown) => setTaggerThresholdError(String(error)))
+ .finally(() => {
+ setTaggerThresholdDraft(null);
+ setTaggerThresholdSaving(false);
+ });
+ } else {
+ setTaggerThresholdDraft(null);
+ setTaggerThresholdError("Must be 0.05 – 0.99");
+ if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
+ thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
+ }
+ }}
+ />
+ {taggerThresholdError ? (
+
{taggerThresholdError}
+ ) : (
+
{taggerThresholdSaving ? "Saving..." : `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}
+ )}
+
+
+
+
+
+
setTaggerBatchSizeDraft(event.target.value)}
+ onBlur={() => {
+ const value = parseInt(batchSizeDisplay, 10);
+ if (!isNaN(value) && value >= 1 && value <= 100) {
+ setTaggerBatchSizeError(null);
+ setTaggerBatchSizeSaving(true);
+ void setTaggerBatchSize(value)
+ .catch((error: unknown) => setTaggerQueueStatus(String(error)))
+ .finally(() => {
+ setTaggerBatchSizeDraft(null);
+ setTaggerBatchSizeSaving(false);
+ });
+ } else {
+ setTaggerBatchSizeDraft(null);
+ setTaggerBatchSizeError("Must be 1 – 100");
+ if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
+ batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
+ }
+ }}
+ />
+ {taggerBatchSizeError ? (
+
{taggerBatchSizeError}
+ ) : (
+
{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}
+ )}
+
+
+
+
+
+
+ {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
+
+ {taggerModelProgress?.current_file ?
{taggerModelProgress.current_file}
: null}
+ {taggerModelError ?
{taggerModelError}
: null}
+
+
+
+
+
+
+
+ All media
+ Selected folders
+
+
+
+
+
+
+
Folder selection
+
Current target: {queueScopeLabel}.
+
+
+ setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
+ disabled={taggingQueueScope === "all" || folders.length === 0}
+ >
+ Select all
+
+ setTaggingQueueFolderIds([])}
+ disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
+ >
+ Clear
+
+
+
+
+
+ {folders.map((folder) => {
+ const active = taggingQueueFolderIds.includes(folder.id);
+ const progress = mediaJobProgress[folder.id];
+ return (
+
toggleTaggingQueueFolder(folder.id)}
+ disabled={taggingQueueScope === "all"}
+ >
+ {folder.name}
+
+ {(progress?.tagging_pending ?? 0) > 0 ? {progress?.tagging_pending} queued : null}
+ {folder.image_count.toLocaleString()} items
+
+
+
+ );
+ })}
+ {folders.length === 0 ?
Add a folder first to enable targeted tagging queues.
: null}
+
+
+
+
+
+ runQueueAction("queue")}
+ disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
+ >
+ {taggerQueueing ? "Queueing..." : "Queue tagging"}
+
+ runQueueAction("clear")}
+ disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
+ >
+ {taggerClearing ? "Clearing..." : "Clear queued jobs"}
+
+
+ {taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"}
+
+ {taggerResetConfirming ? (
+ {
+ setTaggerResetConfirming(false);
+ setTaggerQueueStatus(null);
+ }}
+ disabled={taggerResetting}
+ >
+ Cancel
+
+ ) : null}
+
+
+
+ {taggerQueueStatus ? {taggerQueueStatus}
: null}
+
+
+
+
+
+ Open tag manager
+
+
+
+
+ );
+}
diff --git a/src/components/settings/GeneralSettingsSection.tsx b/src/components/settings/GeneralSettingsSection.tsx
new file mode 100644
index 0000000..de5ae9a
--- /dev/null
+++ b/src/components/settings/GeneralSettingsSection.tsx
@@ -0,0 +1,60 @@
+import { Dropdown } from "../menu";
+import { useGalleryStore } from "../../store";
+import { SettingsGroup, SettingsItem } from "./shared";
+
+export function GeneralSettingsSection() {
+ const theme = useGalleryStore((state) => state.theme);
+ const setTheme = useGalleryStore((state) => state.setTheme);
+ const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
+ const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
+ const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist);
+ const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist);
+
+ return (
+
+
+
+
+
+
+
+
+
+ setNotificationsPaused(!notificationsPaused)}
+ >
+
+
+
+
+ setWorkerPausesPersist(!workerPausesPersist)}
+ >
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/MediaSettingsSection.tsx b/src/components/settings/MediaSettingsSection.tsx
new file mode 100644
index 0000000..d35c2d4
--- /dev/null
+++ b/src/components/settings/MediaSettingsSection.tsx
@@ -0,0 +1,98 @@
+import { Dropdown } from "../menu";
+import { useGalleryStore } from "../../store";
+import { SettingsGroup, SettingsItem } from "./shared";
+
+export function MediaSettingsSection() {
+ const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
+ const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
+ const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
+ const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
+ const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds);
+ const setSlideshowIntervalSeconds = useGalleryStore((state) => state.setSlideshowIntervalSeconds);
+ const slideshowOrder = useGalleryStore((state) => state.slideshowOrder);
+ const setSlideshowOrder = useGalleryStore((state) => state.setSlideshowOrder);
+ const slideshowTransition = useGalleryStore((state) => state.slideshowTransition);
+ const setSlideshowTransition = useGalleryStore((state) => state.setSlideshowTransition);
+
+ return (
+
+
+
+ setLightboxAutoplay(!lightboxAutoplay)}
+ >
+
+
+
+
+ setLightboxAutoMute(!lightboxAutoMute)}
+ >
+
+
+
+
+
+
+
+
+ setSlideshowIntervalSeconds(Number(event.currentTarget.value))}
+ />
+ {slideshowIntervalSeconds}s
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/settings/StorageSettingsSection.tsx b/src/components/settings/StorageSettingsSection.tsx
new file mode 100644
index 0000000..2bb4808
--- /dev/null
+++ b/src/components/settings/StorageSettingsSection.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useState } from "react";
+import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, VacuumResult, useGalleryStore } from "../../store";
+import { SettingsGroup, SettingsItem, settingsButtonClass, StatPair } from "./shared";
+
+export function StorageSettingsSection() {
+ const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
+ const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
+ const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
+ const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
+ const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
+ const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
+
+ const [openingDataFolder, setOpeningDataFolder] = useState(false);
+ const [dbInfo, setDbInfo] = useState(null);
+ const [vacuuming, setVacuuming] = useState(false);
+ const [vacuumResult, setVacuumResult] = useState(null);
+ const [rebuildingIndex, setRebuildingIndex] = useState(false);
+ const [rebuildIndexResult, setRebuildIndexResult] = useState(null);
+ const [thumbnailInfo, setThumbnailInfo] = useState(null);
+ const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
+ const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null);
+
+ useEffect(() => {
+ setVacuumResult(null);
+ setThumbnailCleanupResult(null);
+ void getDatabaseInfo().then(setDbInfo).catch(() => {});
+ void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
+ }, [getDatabaseInfo, getOrphanedThumbnailsInfo]);
+
+ return (
+
+
+
+ {
+ setOpeningDataFolder(true);
+ void openAppDataFolder().finally(() => setOpeningDataFolder(false));
+ }}
+ disabled={openingDataFolder}
+ >
+ {openingDataFolder ? "Opening..." : "Open data folder"}
+
+
+
+
+
+
+ Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.
+
+
+
+
+
+ {vacuumResult
+ ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
+ : dbInfo && dbInfo.reclaimable_mb < 0.5
+ ? "Database is already compact."
+ : "Run this after removing folders or bulk-deleting images."}
+
+ >
+ }
+ >
+ {
+ setVacuuming(true);
+ setVacuumResult(null);
+ void vacuumDatabase()
+ .then((result) => {
+ setVacuumResult(result);
+ setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
+ })
+ .catch(() => {})
+ .finally(() => setVacuuming(false));
+ }}
+ disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
+ >
+ {vacuuming ? "Compacting..." : "Compact now"}
+
+
+
+
+
+ Recreates the visual-embedding index and re-embeds every image in the
+ background. Use this if semantic or similar-image search reports a
+ dimension-mismatch error (for example after experimenting with a
+ different embedding model).
+
+ {rebuildIndexResult !== null ? (
+ {rebuildIndexResult}
+ ) : null}
+ >
+ }
+ >
+ {
+ setRebuildingIndex(true);
+ setRebuildIndexResult(null);
+ void rebuildSemanticIndex()
+ .then((count) =>
+ setRebuildIndexResult(
+ `Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`,
+ ),
+ )
+ .catch((error) => setRebuildIndexResult(String(error)))
+ .finally(() => setRebuildingIndex(false));
+ }}
+ disabled={rebuildingIndex}
+ >
+ {rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
+
+
+
+
+ Thumbnails left behind when folders or images are removed. Safe to delete — they are regenerated if the originals are re-indexed.
+
+
+
+
+
+ {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
+ ? "May take a few minutes for large collections."
+ : "Remove thumbnails no longer associated with any indexed image."}
+
+ >
+ }
+ >
+ {
+ setCleaningThumbnails(true);
+ cleanupOrphanedThumbnails()
+ .then((result) => {
+ setThumbnailCleanupResult(result);
+ setThumbnailInfo(null);
+ })
+ .catch(() => {})
+ .finally(() => setCleaningThumbnails(false));
+ }}
+ disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
+ >
+ {cleaningThumbnails ? "Cleaning…" : "Clean up"}
+
+
+
+
+ );
+}
diff --git a/src/components/settings/UpdatesSettingsSection.tsx b/src/components/settings/UpdatesSettingsSection.tsx
new file mode 100644
index 0000000..5c51a4b
--- /dev/null
+++ b/src/components/settings/UpdatesSettingsSection.tsx
@@ -0,0 +1,113 @@
+import { getChangelogForVersion } from "../../changelog";
+import { useGalleryStore } from "../../store";
+import { FfmpegStatusRow } from "../onboarding/StepWelcome";
+import { SettingsGroup, SettingsItem, settingsButtonClass, StatusPill } from "./shared";
+
+export function UpdatesSettingsSection() {
+ const appVersion = useGalleryStore((state) => state.appVersion);
+ const buildVariant = useGalleryStore((state) => state.buildVariant);
+ const updateStatus = useGalleryStore((state) => state.updateStatus);
+ const updateVersion = useGalleryStore((state) => state.updateVersion);
+ const updateProgress = useGalleryStore((state) => state.updateProgress);
+ const updateError = useGalleryStore((state) => state.updateError);
+ const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
+ const installUpdate = useGalleryStore((state) => state.installUpdate);
+ const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
+ const openOnboarding = useGalleryStore((state) => state.openOnboarding);
+ const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
+
+ return (
+
+
+
+ Phokus {appVersion ? `v${appVersion}` : "—"}
+ {buildVariant ? (
+
+ {buildVariant === "cuda" ? "CUDA" : "CPU"}
+
+ ) : null}
+ {updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
+ v{updateVersion} available
+ ) : updateStatus === "upToDate" ? (
+ Up to date
+ ) : null}
+
+ }
+ description={
+ updateStatus === "error" ? (
+ Update check failed: {updateError}
+ ) : updateStatus === "downloading" || updateStatus === "installing" ? (
+
+
+ {updateStatus === "installing"
+ ? "Installing update…"
+ : updateProgress !== null
+ ? `Downloading update — ${Math.round(updateProgress * 100)}%`
+ : "Downloading update…"}
+
+
+
+
+ The app will restart when it finishes.
+
+ ) : (
+ "Updates are checked quietly at launch and installed only when you choose."
+ )
+ }
+ >
+ {updateStatus === "available" ? (
+ void installUpdate()}
+ >
+ Install & restart
+
+ ) : (
+ void checkForUpdates()}
+ disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
+ >
+ {updateStatus === "checking" ? "Checking..." : "Check for updates"}
+
+ )}
+
+ {getChangelogForVersion(appVersion) ? (
+
+
+ View changes
+
+
+ ) : null}
+
+
+
+
+
+ {
+ setSettingsOpen(false);
+ openOnboarding();
+ }}
+ >
+ Show welcome tour
+
+
+
+
+ );
+}
diff --git a/src/components/settings/shared.tsx b/src/components/settings/shared.tsx
new file mode 100644
index 0000000..5e3380f
--- /dev/null
+++ b/src/components/settings/shared.tsx
@@ -0,0 +1,148 @@
+import { ReactNode } from "react";
+import { TaggerAcceleration, TaggerModel, TaggingQueueScope } from "../../store";
+
+export type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace";
+
+export const SETTINGS_SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
+ { id: "general", label: "General", detail: "Theme and notifications" },
+ { id: "media", label: "Media", detail: "Playback and slideshow" },
+ { id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" },
+ { id: "storage", label: "Storage", detail: "App data and maintenance" },
+ { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
+];
+
+export function formatBytesShort(bytes: number): string {
+ if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
+ return `${(bytes / 1024).toFixed(0)} KB`;
+}
+
+export function StatusPill({ children, tone }: { children: ReactNode; tone: "ready" | "muted" | "busy" }) {
+ const className =
+ tone === "ready"
+ ? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
+ : tone === "busy"
+ ? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"
+ : "border-white/10 bg-white/[0.04] text-gray-500";
+
+ return {children} ;
+}
+
+export function SettingsGroup({ title, description, children }: {
+ title: string;
+ description?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ {title}
+ {description ? {description}
: null}
+ {children}
+
+ );
+}
+
+export function SettingsItem({ label, description, children, vertical = false }: {
+ label: ReactNode;
+ description?: ReactNode;
+ children?: ReactNode;
+ vertical?: boolean;
+}) {
+ if (vertical) {
+ return (
+
+
{label}
+ {description ?
{description}
: null}
+ {children ?
{children}
: null}
+
+ );
+ }
+
+ return (
+
+
+
{label}
+ {description ?
{description}
: null}
+
+
{children}
+
+ );
+}
+
+export function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function ScopeButton({ scope, current, onSelect, children }: {
+ scope: TaggingQueueScope;
+ current: TaggingQueueScope;
+ onSelect: (scope: TaggingQueueScope) => void;
+ children: ReactNode;
+}) {
+ const active = scope === current;
+ return (
+ onSelect(scope)}
+ >
+ {children}
+
+ );
+}
+
+export function TaggerModelButton({ model, current, onSelect, children }: {
+ model: TaggerModel;
+ current: TaggerModel;
+ onSelect: (model: TaggerModel) => void;
+ children: ReactNode;
+}) {
+ const active = model === current;
+ return (
+ onSelect(model)}
+ >
+ {children}
+
+ );
+}
+
+export function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
+ acceleration: TaggerAcceleration;
+ current: TaggerAcceleration;
+ onSelect: (acceleration: TaggerAcceleration) => void;
+ children: ReactNode;
+}) {
+ const active = acceleration === current;
+ return (
+ onSelect(acceleration)}
+ >
+ {children}
+
+ );
+}
+
+export const settingsButtonClass =
+ "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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white";