From 2c0b928bf55a79cf10129bc4fab6be8058d66f5b Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 16:46:41 +0100 Subject: [PATCH] refactor(ui): split SettingsModal into feature slices under src/components/settings/ Extracts the various settings sections (General, Media, Updates, Storage, AI Workspace) from the massive SettingsModal.tsx into distinct sub-components. Introduces a shared.tsx file for common settings UI primitives (SettingsGroup, SettingsItem, StatusPill, etc.) to improve code maintainability and separation of concerns. --- src/components/SettingsModal.tsx | 1138 +---------------- .../settings/AiWorkspaceSettingsSection.tsx | 501 ++++++++ .../settings/GeneralSettingsSection.tsx | 60 + .../settings/MediaSettingsSection.tsx | 98 ++ .../settings/StorageSettingsSection.tsx | 206 +++ .../settings/UpdatesSettingsSection.tsx | 113 ++ src/components/settings/shared.tsx | 148 +++ 7 files changed, 1161 insertions(+), 1103 deletions(-) create mode 100644 src/components/settings/AiWorkspaceSettingsSection.tsx create mode 100644 src/components/settings/GeneralSettingsSection.tsx create mode 100644 src/components/settings/MediaSettingsSection.tsx create mode 100644 src/components/settings/StorageSettingsSection.tsx create mode 100644 src/components/settings/UpdatesSettingsSection.tsx create mode 100644 src/components/settings/shared.tsx 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 ( - - ); -} - -function TaggerModelButton({ model, current, onSelect, children }: { - model: TaggerModel; - current: TaggerModel; - onSelect: (model: TaggerModel) => void; - children: React.ReactNode; -}) { - const active = model === current; - return ( - - ); -} - -function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { - acceleration: TaggerAcceleration; - current: TaggerAcceleration; - onSelect: (acceleration: TaggerAcceleration) => void; - children: React.ReactNode; -}) { - const active = acceleration === current; - return ( - - ); } 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() {
- - - -
-
- {(["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}.

-
-
- - -
-
- -
- {folders.map((folder) => { - const active = taggingQueueFolderIds.includes(folder.id); - const progress = mediaJobProgress[folder.id]; - return ( - - ); - })} - {folders.length === 0 ?

Add a folder first to enable targeted tagging queues.

: null} -
-
- - -
- - - - {taggerResetConfirming ? ( - - ) : null} -
-
- - {taggerQueueStatus ?

{taggerQueueStatus}

: null} -
- - - - - - - - ) : ( -
- {activeSection === "general" ? ( - <> - - - - - - - - - - - - - - - - ) : null} - - {activeSection === "media" ? ( - <> - - - - - - - - - - - -
- setSlideshowIntervalSeconds(Number(event.currentTarget.value))} - /> - {slideshowIntervalSeconds}s -
-
- - - - - - -
- - - ) : null} - - {activeSection === "updates" ? ( - <> - - - 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" ? ( - - ) : ( - - )} - - {getChangelogForVersion(appVersion) ? ( - - - - ) : null} - - - - - - - - - - - ) : null} - - {activeSection === "storage" ? ( - <> - - - - - - - - - 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."} - - - } - > - - - - - - 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} - - } - > - - - - - 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."} - - - } - > - - - - - ) : null} -
- )} + 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 ? ( + <> + + + + ) : ( + + )} +
+ {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}.

+
+
+ + +
+
+ +
+ {folders.map((folder) => { + const active = taggingQueueFolderIds.includes(folder.id); + const progress = mediaJobProgress[folder.id]; + return ( + + ); + })} + {folders.length === 0 ?

Add a folder first to enable targeted tagging queues.

: null} +
+
+ + +
+ + + + {taggerResetConfirming ? ( + + ) : null} +
+
+ + {taggerQueueStatus ?

{taggerQueueStatus}

: null} +
+ + + + + + +
+ ); +} 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 ( +
+ + + + + + + + + + + + + + +
+ ); +} 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 ( +
+ + + + + + + + + + + +
+ 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 ( +
+ + + + + + + + + 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."} + + + } + > + + + + + + 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} + + } + > + + + + + 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."} + + + } + > + + + +
+ ); +} 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" ? ( + + ) : ( + + )} + + {getChangelogForVersion(appVersion) ? ( + + + + ) : null} + + + + + + + + +
+ ); +} 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 ( + + ); +} + +export function TaggerModelButton({ model, current, onSelect, children }: { + model: TaggerModel; + current: TaggerModel; + onSelect: (model: TaggerModel) => void; + children: ReactNode; +}) { + const active = model === current; + return ( + + ); +} + +export function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { + acceleration: TaggerAcceleration; + current: TaggerAcceleration; + onSelect: (acceleration: TaggerAcceleration) => void; + children: ReactNode; +}) { + const active = acceleration === current; + return ( + + ); +} + +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";