From cd7dd89f003c8cfe58185f3f260c49bd90809354 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 12:07:52 +0100 Subject: [PATCH 1/4] feat(settings): flat desktop redesign with proportional sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete presentation rework of the Settings modal away from the card-based tablet look: - Dialog now sizes proportionally (85vw x 85vh, capped at 1400x900) and resizes with the app window instead of a fixed centred square. - Content rebuilt as flat form rows in the VS Code preferences style: uppercase group headings with hairline-divided label/control rows — no nested cards, panels, or boxed strips. Sidebar retained. - Duplicated content header removed; close button floats top-right. - Folder selection restyled from bordered chips to flat divider rows; model path and runtime check render as plain monospace text. Also fixes two maintenance-section bugs: thumbnail-cache stats no longer blank to em-dashes while a cleanup is running, and the Compact now button disables immediately after compaction instead of allowing pointless re-runs until the section remounted. --- src/components/SettingsModal.tsx | 841 +++++++++++++++---------------- 1 file changed, 413 insertions(+), 428 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 190344f..14ec1da 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -16,46 +16,56 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read ? "border-sky-400/25 bg-sky-500/10 text-sky-300" : "border-white/10 bg-white/[0.04] text-gray-500"; - return {children}; + return {children}; } -function SectionShell({ eyebrow, title, description, children }: { - eyebrow: string; +function SettingsGroup({ title, description, children }: { title: string; description?: string; children: React.ReactNode; }) { return (
-

{eyebrow}

-

{title}

- {description ?

{description}

: null} -
{children}
+

{title}

+ {description ?

{description}

: null} +
{children}
); } -function SettingsCard({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { - return ( -
-
-

{title}

- {description ?

{description}

: null} +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}
-
{children}
+ ); + } + + return ( +
+
+

{label}

+ {description ?
{description}
: null} +
+
{children}
); } -function SettingsRow({ title, description, children }: { title: string; description: string; children: React.ReactNode }) { +function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) { return ( -
-
-

{title}

-

{description}

-
-
{children}
-
+ + {label} + {value} + ); } @@ -72,7 +82,7 @@ function ScopeButton({ scope, current, onSelect, children }: { className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ active ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" - : "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200" + : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" }`} onClick={() => onSelect(scope)} > @@ -94,7 +104,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }: className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ active ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" - : "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200" + : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" }`} onClick={() => onSelect(acceleration)} > @@ -277,7 +287,7 @@ export function SettingsModal() { return (
setSettingsOpen(false)}>
event.stopPropagation()} >
-
-
-
-

{activeSection === "workspace" ? "AI Workspace" : "General"}

-

{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}

-
- -
+ + +
+
+

{activeSection === "workspace" ? "AI Workspace" : "General"}

+

{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}

-
{activeSection === "workspace" ? ( -
- - -
-
-
-
-

WD SwinV2 Tagger v3

- - {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} - -
-

- Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds. -

-
-
+
+ + + WD SwinV2 Tagger v3{" "} + + + {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} + + + + } + description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds." + > +
+ {taggerReady ? ( + <> - {taggerReady ? ( - <> - - - - ) : 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={() => { - const value = parseFloat(thresholdDisplay); - if (!isNaN(value) && value >= 0.05 && value <= 0.99) { - setTaggerThresholdError(null); - setTaggerThresholdSaving(true); - void setTaggerThreshold(value) - .catch((error: unknown) => setTaggerThresholdError(String(error))) - .finally(() => { - setTaggerThresholdDraft(null); - setTaggerThresholdSaving(false); - }); - } else { - setTaggerThresholdDraft(null); - setTaggerThresholdError("Must be 0.05 – 0.99"); - if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); - thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000); - } - }} - /> - {taggerThresholdError ? ( -

{taggerThresholdError}

- ) : ( -

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

- )} -
-
- - -
- 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"}

- )} -
-
- -
-

Model location

-

- {taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"} -

- {taggerModelProgress?.current_file ?

{taggerModelProgress.current_file}

: null} - {taggerModelError ?

{taggerModelError}

: null} - {taggerRuntimeProbe ? ( -
-
-

Runtime check

- Ready -
-

Tagger acceleration: {taggerRuntimeProbe.acceleration}

-

{taggerRuntimeProbe.session.file}

-
- ) : null} -
-
+ + + ) : ( + + )}
- + - - -
- All media - Selected folders -
-
- -
-
-
-

Folder selection

-

Current target: {queueScopeLabel}.

-
-
- - -
+ {acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"} + + ))}
+ {taggerAccelerationError ? ( +

{taggerAccelerationError}

+ ) : ( +

{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}

+ )} +
+ -
- {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} + +
+ setTaggerThresholdDraft(event.target.value)} + onBlur={() => { + const value = parseFloat(thresholdDisplay); + if (!isNaN(value) && value >= 0.05 && value <= 0.99) { + setTaggerThresholdError(null); + setTaggerThresholdSaving(true); + void setTaggerThreshold(value) + .catch((error: unknown) => setTaggerThresholdError(String(error))) + .finally(() => { + setTaggerThresholdDraft(null); + setTaggerThresholdSaving(false); + }); + } else { + setTaggerThresholdDraft(null); + setTaggerThresholdError("Must be 0.05 – 0.99"); + if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); + thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000); + } + }} + /> + {taggerThresholdError ? ( +

{taggerThresholdError}

+ ) : ( +

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+ )} +
+
+ + +
+ 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} + {taggerRuntimeProbe ? ( +
+

+ Runtime check Ready + acceleration: {taggerRuntimeProbe.acceleration} +

+

{taggerRuntimeProbe.session.file}

+
+ ) : 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} +
+
- {taggerQueueStatus ?

{taggerQueueStatus}

: null} - - + +
+ + +
+
+ + {taggerQueueStatus ?

{taggerQueueStatus}

: null} +
) : ( -
- - -
-

Open the app data folder in Explorer to inspect or back up files.

- -
-
- - + + + + + -
-
-

Pause all notifications

-

Suppress all indexing notifications until re-enabled.

-
- -
-
+ + + - -
-
-
-

Database size

-

- {vacuumResult - ? `${vacuumResult.after_mb.toFixed(1)} MB` - : dbInfo - ? `${dbInfo.size_mb.toFixed(1)} MB` - : "—"} -

-
-
-

Reclaimable

-

- {vacuumResult - ? `−${vacuumResult.freed_mb.toFixed(1)} MB freed` - : dbInfo - ? `${dbInfo.reclaimable_mb.toFixed(1)} MB` - : "—"} -

-
-
-
-

+ + + 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."} -

- -
-
-
- - + + } > -
-
-
-

Orphaned files

-

- {cleaningThumbnails - ? "—" - : thumbnailCleanupResult + + + + + Thumbnails left behind when folders or images are removed. Safe to delete — they are regenerated if the originals are re-indexed. + + -

-
-

Reclaimable

-

- {cleaningThumbnails - ? "—" - : thumbnailCleanupResult - ? "0 MB" + : "—" + } + /> + -

-
-
-

+ : "—" + } + /> + + {cleaningThumbnails ? "Scanning and removing orphaned thumbnails…" : thumbnailCleanupResult @@ -719,29 +703,30 @@ export function SettingsModal() { : thumbnailInfo && thumbnailInfo.count === 0 ? "No orphaned thumbnails found." : thumbnailInfo && thumbnailInfo.count > 1000 - ? "Remove thumbnails no longer associated with any indexed image. This may take a few minutes for large collections." + ? "May take a few minutes for large collections." : "Remove thumbnails no longer associated with any indexed image."} -

- -
-
-
-
+ + + } + > + + +
)}
From b02bf1da2b8ece580391400b183c0838ad7d06fa Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 12:39:04 +0100 Subject: [PATCH 2/4] feat(views): in-view folder scope switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline, Explore, and Duplicates get a folder-scope dropdown in their headers (Timeline via the shared Toolbar), so changing scope no longer means leaving the feature and bouncing through the sidebar. The new setViewFolderScope store action updates selectedFolderId while preserving activeView: Timeline/Gallery reload images, Explore reloads via its existing selectedFolderId effect, and Duplicates loads the cached results for the new scope (fresh scans remain an explicit Rescan). Sidebar behavior is unchanged — clicking a library still opens its gallery. --- src/components/DuplicateFinder.tsx | 2 + src/components/FolderScopeDropdown.tsx | 98 ++++++++++++++++++++++++++ src/components/TagCloud.tsx | 38 +++++----- src/components/Toolbar.tsx | 4 ++ src/store.ts | 29 ++++++++ 5 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 src/components/FolderScopeDropdown.tsx diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 67b361b..ce9c344 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { convertFileSrc } from "@tauri-apps/api/core"; import { DuplicateGroup, useGalleryStore } from "../store"; +import { FolderScopeDropdown } from "./FolderScopeDropdown"; function formatBytes(bytes: number): string { if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; @@ -188,6 +189,7 @@ export function DuplicateFinder() { )}
+ {/* Batch select — only shown when there are groups and nothing is selected yet */} {hasResults && selectedCount === 0 && !deleting && ( + {open ? ( +
+ + {folders.map((folder) => { + const active = selectedFolderId === folder.id; + return ( + + ); + })} +
+ ) : null} +
+ ); +} diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 43d2fcc..fffae2f 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion } from "framer-motion"; import { convertFileSrc } from "@tauri-apps/api/core"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; +import { FolderScopeDropdown } from "./FolderScopeDropdown"; const ACCENTS = [ "#60a5fa", @@ -317,23 +318,26 @@ export function TagCloud() { : "No tags — run the AI tagger or add tags manually"}

-
- - +
+ +
+ + +
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 02f4041..c276410 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; +import { FolderScopeDropdown } from "./FolderScopeDropdown"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, @@ -162,6 +163,7 @@ export function Toolbar() { const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const zoomPreset = useGalleryStore((state) => state.zoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); + const activeView = useGalleryStore((state) => state.activeView); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); @@ -269,6 +271,8 @@ export function Toolbar() { )}
+ {activeView === "timeline" ? : null} +
{/* Search */} diff --git a/src/store.ts b/src/store.ts index 1fd5eb6..f5364b9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -337,6 +337,7 @@ interface GalleryState { renameFolder: (folderId: number, newName: string) => Promise; updateFolderPath: (folderId: number, newPath: string) => Promise; selectFolder: (folderId: number | null) => void; + setViewFolderScope: (folderId: number | null) => void; loadImages: (reset?: boolean) => Promise; loadMoreImages: () => Promise; setSearch: (search: string) => void; @@ -755,6 +756,34 @@ export const useGalleryStore = create((set, get) => ({ void get().loadImages(true); }, + // Change folder scope from inside a feature view (Timeline/Explore/Duplicates) + // without leaving it — unlike selectFolder, activeView is preserved. + setViewFolderScope: (folderId) => { + const { activeView, selectedFolderId } = get(); + if (folderId === selectedFolderId) return; + + set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + + if (activeView === "duplicates") { + const { duplicateScanFolderId } = get(); + if (duplicateScanFolderId !== folderId) { + set({ + duplicateGroups: [], + duplicateLastScanned: null, + duplicateScanFolderId: undefined, + duplicateScanWarning: null, + }); + void get().loadDuplicateScanCache(folderId); + } + return; + } + + // Explore reloads itself via TagCloud's useEffect on selectedFolderId. + if (activeView === "explore") return; + + void get().loadImages(true); + }, + loadImages: async (reset = false) => { const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly } = get(); const parsedSearch = parseSearchValue(search); From 86ce7bc8e2f7387adeab3374f388d1c15a27622c Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 12 Jun 2026 12:39:04 +0100 Subject: [PATCH 3/4] feat(lightbox): custom immersive video player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the native