From e374ff6b028b6436ba33c73dc975e18312e67136 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 3 Jul 2026 23:26:10 +0100 Subject: [PATCH 01/27] fix(ui): fix tile tooltip + folder picker - Tooltips now shows their correct px size on hovering - Remove trailing `\` from drive letter in folder picker UI --- src/components/FolderPickerModal.tsx | 2 +- src/components/Toolbar.tsx | 39 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index 4b46aaf..a437c97 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -41,7 +41,7 @@ function buildBreadcrumbs(path: string | null): { label: string; path: string | const windowsDrive = normalized.match(/^[A-Za-z]:/); if (windowsDrive) { - const drive = windowsDrive[0] + "\\"; + const drive = windowsDrive[0]; const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean); let current = drive; return [ diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 42dd5aa..16b2d0b 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -187,7 +187,6 @@ export function Toolbar() { const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); const title = collectionTitle ?? (selectedFolder ? selectedFolder.name : "All Media"); - const tileSize = tileSizeForZoom(zoomPreset); const sortOptions = getSortOptions(mediaFilter); const hasActiveSearch = search.trim().length > 0; const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery)); @@ -439,22 +438,28 @@ export function Toolbar() { {/* Zoom */}
- {(["compact", "comfortable", "detail"] as const).map((preset, i) => ( - - - - ))} + {(["compact", "comfortable", "detail"] as const).map((preset, i) => { + const presetSize = tileSizeForZoom(preset); + const presetLabel = + preset === "compact" ? "" : preset === "comfortable" ? "" : ""; + return ( + + + + ); + })}
From 83081928f676f58d6a73df3dd3e0cbc0c25c2d9d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 3 Jul 2026 23:54:28 +0100 Subject: [PATCH 02/27] refactor(ui): unify context menus into a shared menu system Five hand-rolled context menu implementations (image tiles, sidebar folders, sidebar albums, title bar theme switcher, plus duplicated close-listener effects in Gallery/Timeline) are replaced by shared primitives in src/components/menu/: - useDismissable: one outside-pointerdown + Escape dismissal hook - Menu.tsx: MenuPanel chrome, MenuItem (danger/disabled/checked/hint), MenuSeparator, MenuLabel, and SubMenu with viewport edge-flipping - ContextMenu: portal-rendered wrapper that measures and clamps to the viewport, fixing menus rendering off-screen and the latent fixed-inside-transform bug under framer-motion Reorder items The image right-click menu moves to ImageContextMenu.tsx, shared by Gallery and Timeline. --- CHANGELOG.md | 3 + src/components/Gallery.tsx | 107 +------------- src/components/ImageContextMenu.tsx | 70 +++++++++ src/components/Sidebar.tsx | 187 ++++-------------------- src/components/Timeline.tsx | 21 +-- src/components/TitleBar.tsx | 57 ++------ src/components/menu/ContextMenu.tsx | 67 +++++++++ src/components/menu/Menu.tsx | 198 ++++++++++++++++++++++++++ src/components/menu/index.ts | 4 + src/components/menu/useDismissable.ts | 28 ++++ 10 files changed, 414 insertions(+), 328 deletions(-) create mode 100644 src/components/ImageContextMenu.tsx create mode 100644 src/components/menu/ContextMenu.tsx create mode 100644 src/components/menu/Menu.tsx create mode 100644 src/components/menu/index.ts create mode 100644 src/components/menu/useDismissable.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7e5b6..b4a310a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed +- **Consistent right-click menus** — the folder, album, image, and theme menus + now share one look and behavior: they stay inside the window instead of + spilling off the edge, close on Escape, and support proper submenus. - **Neater lightbox details** — image and video metadata now sits in two columns, so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 85bc8f3..fd70c86 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -2,6 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } fr import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { BulkActionBar } from "./BulkActionBar"; +import { ImageContextMenu } from "./ImageContextMenu"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -19,94 +20,6 @@ function formatDuration(durationMs: number | null): string | null { return `${minutes}:${seconds.toString().padStart(2, "0")}`; } -export function ContextMenu({ - x, - y, - image, - onClose, -}: { - x: number; - y: number; - image: ImageRecord; - onClose: () => void; -}) { - const openImage = useGalleryStore((state) => state.openImage); - const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); - const findSimilar = useGalleryStore((state) => state.findSimilar); - const canFindSimilar = image.embedding_status === "ready"; - - return ( -
event.stopPropagation()} - > - - - -
-
Rating
-
- {Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; - return ( - - - - ); - })} - {image.rating > 0 ? ( - - - - ) : null} -
-
- ); -} - export function ImageTile({ image, onClick, @@ -395,22 +308,6 @@ export function Gallery() { return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); - useEffect(() => { - const close = (event: PointerEvent) => { - if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; - setContextMenu(null); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setContextMenu(null); - }; - window.addEventListener("pointerdown", close); - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("pointerdown", close); - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); - return (
@@ -515,7 +412,7 @@ export function Gallery() { ) : null} {contextMenu ? ( - void; +}) { + const openImage = useGalleryStore((state) => state.openImage); + const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); + const findSimilar = useGalleryStore((state) => state.findSimilar); + const canFindSimilar = image.embedding_status === "ready"; + + return ( + + openImage(image)} /> + void updateImageDetails(image.id, { favorite: !image.favorite })} + /> + findSimilar(image.id, image.folder_id)} + /> + + Rating +
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1; + return ( + + + + ); + })} + {image.rating > 0 ? ( + + + + ) : null} +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index cfe87cc..95a3031 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,88 +3,13 @@ import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { ThemedDropdown } from "./ThemedDropdown"; +import { ContextMenu, MenuItem, MenuSeparator } from "./menu"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; -interface ContextMenuState { - folderId: number; - x: number; - y: number; -} - type LibrarySort = "az" | "za" | "custom"; const LIBRARY_SORT_KEY = "phokus-library-sort"; -function FolderContextMenu({ - menu, - folder, - isMuted, - isPausedAll, - onClose, - onRename, - onReindex, - onLocate, - onToggleMute, - onTogglePauseAll, - onRemove, -}: { - menu: ContextMenuState; - folder: Folder; - isMuted: boolean; - isPausedAll: boolean; - onClose: () => void; - onRename: () => void; - onReindex: () => void; - onLocate: () => void; - onToggleMute: () => void; - onTogglePauseAll: () => void; - onRemove: () => void; -}) { - const ref = useRef(null); - - useEffect(() => { - const handleDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [onClose]); - - const item = (label: string, onClick: () => void, danger = false) => ( - - ); - - return ( -
- {item("Reindex", onReindex)} - {item("Rename", onRename)} - {item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)} - {item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)} - {folder.scan_error && item("Locate Folder", onLocate)} -
- {item("Remove from app", onRemove, true)} -
- ); -} - function FolderItem({ folder, selected, @@ -116,7 +41,7 @@ function FolderItem({ const isIndexing = progress && !progress.done; const isMissing = !!folder.scan_error && !isIndexing; - const [contextMenu, setContextMenu] = useState(null); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(folder.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); @@ -132,10 +57,7 @@ function FolderItem({ const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - // Keep menu inside viewport - const x = Math.min(e.clientX, window.innerWidth - 180); - const y = Math.min(e.clientY, window.innerHeight - 160); - setContextMenu({ folderId: folder.id, x, y }); + setContextMenu({ x: e.clientX, y: e.clientY }); }; const handleLocateFolder = async () => { @@ -325,84 +247,27 @@ function FolderItem({
)} - {contextMenu && contextMenu.folderId === folder.id && ( - setContextMenu(null)} - onRename={() => setRenaming(true)} - onReindex={() => void reindexFolder(folder.id)} - onLocate={() => void handleLocateFolder()} - onToggleMute={() => toggleMutedFolder(folder.id)} - onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)} - onRemove={() => setConfirmingRemoval(true)} - /> + {contextMenu && ( + setContextMenu(null)}> + void reindexFolder(folder.id)} /> + setRenaming(true)} /> + setAllWorkersPaused(folder.id, !isPausedAll)} + /> + toggleMutedFolder(folder.id)} + /> + {folder.scan_error ? void handleLocateFolder()} /> : null} + + setConfirmingRemoval(true)} /> + )} ); } -function AlbumContextMenu({ - x, - y, - onClose, - onRename, - onDelete, -}: { - x: number; - y: number; - onClose: () => void; - onRename: () => void; - onDelete: () => void; -}) { - const ref = useRef(null); - useEffect(() => { - const handleDown = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) onClose(); - }; - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [onClose]); - - const item = (label: string, onClick: () => void, danger = false) => ( - - ); - - return ( -
- {item("Rename", onRename)} -
- {item("Delete album", onDelete, true)} -
- ); -} - function AlbumItem({ album, manageMode = false, @@ -485,7 +350,7 @@ function AlbumItem({ if (manageMode) return; e.preventDefault(); e.stopPropagation(); - setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) }); + setMenu({ x: e.clientX, y: e.clientY }); }} > {/* Manage-mode selection checkbox */} @@ -578,13 +443,11 @@ function AlbumItem({ ) : null} {menu ? ( - setMenu(null)} - onRename={() => setRenaming(true)} - onDelete={() => setConfirmingRemoval(true)} - /> + setMenu(null)}> + setRenaming(true)} /> + + setConfirmingRemoval(true)} /> + ) : null}
); diff --git a/src/components/Timeline.tsx b/src/components/Timeline.tsx index b150751..80b3137 100644 --- a/src/components/Timeline.tsx +++ b/src/components/Timeline.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; -import { ContextMenu, ImageTile } from "./Gallery"; +import { ImageTile } from "./Gallery"; +import { ImageContextMenu } from "./ImageContextMenu"; import { Tooltip } from "./Tooltip"; const GAP = 6; @@ -204,22 +205,6 @@ export function Timeline() { return () => el.removeEventListener("scroll", handleScroll); }, [handleScroll]); - useEffect(() => { - const close = (e: PointerEvent) => { - if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; - setContextMenu(null); - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setContextMenu(null); - }; - window.addEventListener("pointerdown", close); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("pointerdown", close); - window.removeEventListener("keydown", onKey); - }; - }, []); - const scrollToGroup = useCallback( (groupIndex: number) => { const row = groupFirstRowRef.current[groupIndex] ?? 0; @@ -353,7 +338,7 @@ export function Timeline() { ) : null} {contextMenu ? ( - (null); - const themeMenuRef = useRef(null); - const [themeMenu, setThemeMenu] = useState<{ top: number; right: number } | null>(null); - - useEffect(() => { - if (!themeMenu) return; - const handleDown = (e: MouseEvent) => { - if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) setThemeMenu(null); - }; - const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setThemeMenu(null); }; - document.addEventListener("mousedown", handleDown); - document.addEventListener("keydown", handleKey); - return () => { - document.removeEventListener("mousedown", handleDown); - document.removeEventListener("keydown", handleKey); - }; - }, [themeMenu]); + const [themeMenu, setThemeMenu] = useState<{ x: number; y: number } | null>(null); const handleSettingsContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const rect = settingsBtnRef.current?.getBoundingClientRect(); if (!rect) return; - setThemeMenu({ top: rect.bottom + 4, right: window.innerWidth - rect.right }); + setThemeMenu({ x: rect.right, y: rect.bottom + 4 }); }; useEffect(() => { @@ -188,32 +174,17 @@ export function TitleBar() { {/* Quick theme switcher — opened by right-clicking the settings cog. */} {themeMenu && ( -
-
Theme
- {THEME_OPTIONS.map((opt) => { - const active = theme === opt.value; - return ( - - ); - })} -
+ setThemeMenu(null)}> + Theme + {THEME_OPTIONS.map((opt) => ( + setTheme(opt.value)} + /> + ))} + )}
); diff --git a/src/components/menu/ContextMenu.tsx b/src/components/menu/ContextMenu.tsx new file mode 100644 index 0000000..2a01698 --- /dev/null +++ b/src/components/menu/ContextMenu.tsx @@ -0,0 +1,67 @@ +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { MenuCloseContext, MenuPanel, MenuSize } from "./Menu"; +import { useDismissable } from "./useDismissable"; + +/** + * Positioned floating menu. Renders through a portal so it is never caught + * inside transformed ancestors (framer-motion drag/layout items), and clamps + * itself to the viewport after measuring its real size. + * + * `align="start"` puts the panel's top-left at (x, y) — right-click menus. + * `align="end"` puts the top-right at (x, y) — menus anchored under a + * right-edge button. + */ +export function ContextMenu({ + x, + y, + onClose, + size = "md", + align = "start", + className, + children, +}: { + x: number; + y: number; + onClose: () => void; + size?: MenuSize; + align?: "start" | "end"; + className?: string; + children: ReactNode; +}) { + const ref = useRef(null); + const [pos, setPos] = useState<{ left: number; top: number } | null>(null); + + useDismissable(ref, onClose); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const margin = 8; + const rect = el.getBoundingClientRect(); + const desiredLeft = align === "end" ? x - rect.width : x; + setPos({ + left: Math.max(margin, Math.min(desiredLeft, window.innerWidth - rect.width - margin)), + top: Math.max(margin, Math.min(y, window.innerHeight - rect.height - margin)), + }); + }, [x, y, align]); + + return createPortal( + , + document.body, + ); +} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx new file mode 100644 index 0000000..ae44ab5 --- /dev/null +++ b/src/components/menu/Menu.tsx @@ -0,0 +1,198 @@ +import { + ReactNode, + createContext, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +export type MenuSize = "sm" | "md"; + +/** Provided by ContextMenu so any nested MenuItem (submenus included) can close the whole menu. */ +export const MenuCloseContext = createContext<() => void>(() => {}); +const MenuSizeContext = createContext("md"); + +function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { + const base = + size === "sm" + ? "w-full rounded-md px-3 py-1.5 text-[12px]" + : "w-full rounded-lg px-3 py-2 text-sm"; + const tone = disabled + ? "text-gray-600 cursor-not-allowed" + : danger + ? "text-red-400 hover:bg-red-500/15 hover:text-red-300" + : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; + return `${base} ${tone} transition-colors`; +} + +/** + * The panel chrome shared by every menu surface. Size defaults to the + * enclosing menu's size so submenu panels match their parent automatically. + */ +export function MenuPanel({ + size, + className = "", + children, +}: { + size?: MenuSize; + className?: string; + children: ReactNode; +}) { + const inherited = useContext(MenuSizeContext); + const resolved = size ?? inherited; + return ( + +
+ {children} +
+
+ ); +} + +export function MenuItem({ + label, + onSelect, + danger = false, + disabled = false, + checked, + hint, + keepOpen = false, +}: { + label: ReactNode; + onSelect?: () => void; + danger?: boolean; + disabled?: boolean; + /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */ + checked?: boolean; + hint?: ReactNode; + /** Skip the automatic menu close after selecting. */ + keepOpen?: boolean; +}) { + const size = useContext(MenuSizeContext); + const close = useContext(MenuCloseContext); + return ( + + ); +} + +export function MenuSeparator() { + return
; +} + +export function MenuLabel({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +/** + * A nested menu panel that opens beside its row on hover (or click, for + * touch). Flips to the left edge and shifts up when it would leave the + * viewport. Items inside close the entire menu tree via MenuCloseContext. + */ +export function SubMenu({ + label, + disabled = false, + children, + panelClassName = "", +}: { + label: ReactNode; + disabled?: boolean; + children: ReactNode; + panelClassName?: string; +}) { + const size = useContext(MenuSizeContext); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState({ flipX: false, shiftY: 0 }); + const panelRef = useRef(null); + const closeTimer = useRef | null>(null); + + useEffect( + () => () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + }, + [], + ); + + const openNow = () => { + if (disabled) return; + if (closeTimer.current) { + clearTimeout(closeTimer.current); + closeTimer.current = null; + } + setOpen(true); + }; + // Grace delay so the pointer can cross the small gap to the panel. + const closeSoon = () => { + if (closeTimer.current) clearTimeout(closeTimer.current); + closeTimer.current = setTimeout(() => setOpen(false), 140); + }; + + useLayoutEffect(() => { + if (!open) { + setPlacement({ flipX: false, shiftY: 0 }); + return; + } + const panel = panelRef.current; + if (!panel) return; + const margin = 8; + const rect = panel.getBoundingClientRect(); + const overflowY = rect.bottom - (window.innerHeight - margin); + setPlacement({ + flipX: rect.right > window.innerWidth - margin, + shiftY: overflowY > 0 ? -overflowY : 0, + }); + }, [open]); + + return ( +
+ + {open ? ( +
+ {children} +
+ ) : null} +
+ ); +} diff --git a/src/components/menu/index.ts b/src/components/menu/index.ts new file mode 100644 index 0000000..b38115a --- /dev/null +++ b/src/components/menu/index.ts @@ -0,0 +1,4 @@ +export { ContextMenu } from "./ContextMenu"; +export { MenuItem, MenuLabel, MenuPanel, MenuSeparator, SubMenu } from "./Menu"; +export type { MenuSize } from "./Menu"; +export { useDismissable } from "./useDismissable"; diff --git a/src/components/menu/useDismissable.ts b/src/components/menu/useDismissable.ts new file mode 100644 index 0000000..61e3bc0 --- /dev/null +++ b/src/components/menu/useDismissable.ts @@ -0,0 +1,28 @@ +import { RefObject, useEffect } from "react"; + +/** + * Closes a floating element on pointer-down outside `ref` or on Escape. + * The single shared dismissal behavior for menus, dropdowns, and popovers. + */ +export function useDismissable( + ref: RefObject, + onClose: () => void, + enabled = true, +) { + useEffect(() => { + if (!enabled) return; + const handlePointerDown = (event: PointerEvent) => { + const el = ref.current; + if (el && !el.contains(event.target as Node)) onClose(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ref, onClose, enabled]); +} From 5d46ee5b9428fb3ee3fae0433880910a56c02c71 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Fri, 3 Jul 2026 23:54:40 +0100 Subject: [PATCH 03/27] feat(gallery): add-to-album submenu on the image right-click menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking an image in the Gallery or Timeline now offers an "Add to Album" submenu listing all albums with their counts — the first use of the new SubMenu primitive. Filing a single image away no longer requires starting a multi-select. --- CHANGELOG.md | 10 +++++++--- src/components/ImageContextMenu.tsx | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a310a..d7870bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,12 +56,16 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) from whatever collection you are already browsing. Videos are skipped, controls tuck themselves away after a few seconds, and Settings lets you pick the pace and whether playback follows the current order or goes random. +- **Add to album from the right-click menu** — right-click any image in the + Gallery or Timeline and file it straight into an album from the new "Add to + Album" submenu. One image, one click, zero ceremony. ### Changed -- **Consistent right-click menus** — the folder, album, image, and theme menus - now share one look and behavior: they stay inside the window instead of - spilling off the edge, close on Escape, and support proper submenus. +- **Right-click menus got their act together** — images, folders, albums, and + the theme switcher now share one menu style with one set of manners: they + stay on screen instead of wandering off the edge, all close on Escape, and + they can do proper submenus now. - **Neater lightbox details** — image and video metadata now sits in two columns, so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx index 5b81bb4..24dc822 100644 --- a/src/components/ImageContextMenu.tsx +++ b/src/components/ImageContextMenu.tsx @@ -1,5 +1,5 @@ import { ImageRecord, useGalleryStore } from "../store"; -import { ContextMenu, MenuItem, MenuLabel, MenuSeparator } from "./menu"; +import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from "./menu"; import { Tooltip } from "./Tooltip"; /** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */ @@ -17,6 +17,8 @@ export function ImageContextMenu({ const openImage = useGalleryStore((state) => state.openImage); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const findSimilar = useGalleryStore((state) => state.findSimilar); + const albums = useGalleryStore((state) => state.albums); + const addToAlbum = useGalleryStore((state) => state.addToAlbum); const canFindSimilar = image.embedding_status === "ready"; return ( @@ -31,6 +33,20 @@ export function ImageContextMenu({ disabled={!canFindSimilar} onSelect={() => findSimilar(image.id, image.folder_id)} /> + + {albums.length === 0 ? ( + + ) : ( + albums.map((album) => ( + void addToAlbum(album.id, [image.id])} + /> + )) + )} + Rating
From 54016df8305a92a091ff0349ab2271f9e6fe6309 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 07:22:09 +0100 Subject: [PATCH 04/27] refactor(ui): adopt useDismissable in ColorFilter and BulkActionBar Replaces the hand-rolled outside-pointerdown listeners with the shared hook. Both popovers now also close on Escape, and listeners only attach while a panel is actually open. --- src/components/BulkActionBar.tsx | 12 +++--------- src/components/ColorFilter.tsx | 14 ++++---------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 328b175..a0340f5 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useGalleryStore } from "../store"; import { BulkTagPopover } from "./bulk/BulkTagPopover"; +import { useDismissable } from "./menu"; import { Tooltip } from "./Tooltip" type Panel = "tag" | "rating" | "album" | "delete" | null; @@ -28,15 +29,8 @@ export function BulkActionBar() { const [newAlbumName, setNewAlbumName] = useState(""); const barRef = useRef(null); - // Close any open popover when clicking outside the bar. - useEffect(() => { - const onPointerDown = (event: PointerEvent) => { - if (barRef.current?.contains(event.target as Node)) return; - setPanel(null); - }; - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, []); + // Close any open popover when clicking outside the bar or pressing Escape. + useDismissable(barRef, () => setPanel(null), panel !== null); // Reset transient UI whenever the selection empties. useEffect(() => { diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx index 200ed7e..bdb80f5 100644 --- a/src/components/ColorFilter.tsx +++ b/src/components/ColorFilter.tsx @@ -1,6 +1,7 @@ -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { useGalleryStore } from "../store"; +import { useDismissable } from "./menu"; import { Tooltip } from "./Tooltip"; type Rgb = [number, number, number]; @@ -45,15 +46,8 @@ export function ColorFilter() { const isActive = colorFilter !== null; const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb)); - // Collapse the panel when clicking elsewhere. - useEffect(() => { - if (!open) return; - const onPointerDown = (event: PointerEvent) => { - if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false); - }; - window.addEventListener("pointerdown", onPointerDown); - return () => window.removeEventListener("pointerdown", onPointerDown); - }, [open]); + // Collapse the panel when clicking elsewhere or pressing Escape. + useDismissable(ref, () => setOpen(false), open); return (
From 053a2bd84699a67e6b6c623c471cdf49753405a9 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 07:22:22 +0100 Subject: [PATCH 05/27] refactor(ui): consolidate dropdowns into one shared Dropdown ThemedDropdown, Toolbar''s local SortDropdown, and FolderScopeDropdown were three hand-rolled implementations of the same select pattern. They are replaced by a single generic Dropdown (src/components/menu/) built on MenuPanel/MenuItem, with solid/ghost/compact trigger variants and Object.is value comparison so number|null folder scopes work alongside string unions. Call sites drop their `value as X` casts. MenuItem gains an `active` state plus stable menu-panel/menu-item class hooks, and the subtle-light CSS that previously dressed only the folder scope dropdown (feature-scope-*) now themes every menu surface -- dropdowns, context menus, and submenus alike. --- CHANGELOG.md | 10 +- src/components/ExploreView.tsx | 6 +- src/components/FolderScopeDropdown.tsx | 113 ++++++-------------- src/components/SettingsModal.tsx | 16 +-- src/components/Sidebar.tsx | 10 +- src/components/ThemedDropdown.tsx | 106 ------------------- src/components/Toolbar.tsx | 85 ++-------------- src/components/menu/Dropdown.tsx | 136 +++++++++++++++++++++++++ src/components/menu/Menu.tsx | 36 ++++++- src/components/menu/index.ts | 4 +- src/index.css | 18 ++-- 11 files changed, 243 insertions(+), 297 deletions(-) delete mode 100644 src/components/ThemedDropdown.tsx create mode 100644 src/components/menu/Dropdown.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index d7870bc..69cc5cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,10 +62,12 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ### Changed -- **Right-click menus got their act together** — images, folders, albums, and - the theme switcher now share one menu style with one set of manners: they - stay on screen instead of wandering off the edge, all close on Escape, and - they can do proper submenus now. +- **Menus got their act together** — right-click menus (images, folders, + albums, the theme switcher) and every dropdown (sort, folder scope, settings, + sidebar) now share one style with one set of manners: they stay on screen + instead of wandering off the edge, all close on Escape, and right-click menus + can do proper submenus now. Subtle Light dresses them all the same way too, + instead of saving the nice outfit for one dropdown. - **Neater lightbox details** — image and video metadata now sits in two columns, so the info panel shows more at a glance with less scrolling. - **Faster Explore revisits** — returning to a folder's visual clusters should diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index 76a522b..d69be2d 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -3,7 +3,7 @@ import { motion, useReducedMotion } from "framer-motion"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { ThemedDropdown } from "./ThemedDropdown"; +import { Dropdown } from "./menu"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -1036,9 +1036,9 @@ function TagManageList({ ) : null}
- setSort(value as TagManageSort)} + onChange={setSort} options={TAG_MANAGE_SORTS} ariaLabel="Sort managed tags" align="right" diff --git a/src/components/FolderScopeDropdown.tsx b/src/components/FolderScopeDropdown.tsx index 9bbed40..c2de0c2 100644 --- a/src/components/FolderScopeDropdown.tsx +++ b/src/components/FolderScopeDropdown.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; +import { useMemo } from "react"; import { useGalleryStore } from "../store"; -import { Tooltip } from "./Tooltip"; +import { Dropdown, DropdownOption } from "./menu"; /** * In-view folder scope picker for feature views (Timeline / Explore / @@ -8,93 +8,38 @@ import { Tooltip } from "./Tooltip"; * current view active — unlike sidebar folder clicks, which jump to Gallery. */ export function FolderScopeDropdown() { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope); - useEffect(() => { - const close = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - window.addEventListener("pointerdown", close); - return () => window.removeEventListener("pointerdown", close); - }, []); - - const currentLabel = - selectedFolderId === null - ? "All Media" - : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media"; - - const select = (folderId: number | null) => { - setViewFolderScope(folderId); - setOpen(false); - }; + const options = useMemo[]>( + () => [ + { value: null, label: "All Media" }, + ...folders.map((folder) => ({ + value: folder.id, + label: folder.name, + hint: {folder.image_count.toLocaleString()}, + })), + ], + [folders], + ); return ( -
- - - - {open ? ( -
- - {folders.map((folder) => { - const active = selectedFolderId === folder.id; - return ( - - ); - })} -
- ) : null} -
+ + + + } + /> ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3a38264..cb6ed6c 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, SlideshowOrder, SlideshowTransition, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; -import { ThemedDropdown } from "./ThemedDropdown"; +import { Dropdown } from "./menu"; import { getChangelogForVersion } from "../changelog"; import { Tooltip } from "./Tooltip"; import { TAGGER_MODELS } from "../taggerModels"; @@ -776,9 +776,9 @@ export function SettingsModal() { <> - setTheme(value as AppTheme)} + onChange={setTheme} ariaLabel="App theme" options={[ { value: "phokus", label: "Phokus" }, @@ -874,9 +874,9 @@ export function SettingsModal() { label="Playback order" description="Sequential follows the current lightbox order. Random picks another image from the same collection." > - setSlideshowOrder(value as SlideshowOrder)} + onChange={setSlideshowOrder} ariaLabel="Slideshow order" options={[ { value: "sequential", label: "Sequential" }, @@ -888,9 +888,9 @@ export function SettingsModal() { label="Transition" description="Soft fade keeps images still. Gentle motion adds a slow, subtle drift while the next image settles in." > - setSlideshowTransition(value as SlideshowTransition)} + onChange={setSlideshowTransition} ariaLabel="Slideshow transition" options={[ { value: "soft-fade", label: "Soft fade" }, diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 95a3031..a9f821c 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; -import { ThemedDropdown } from "./ThemedDropdown"; -import { ContextMenu, MenuItem, MenuSeparator } from "./menu"; +import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; @@ -788,11 +787,12 @@ export function Sidebar() { {folders.length > 0 && (
Libraries - setLibrarySort(value as LibrarySort)} + onChange={setLibrarySort} ariaLabel="Library order" - compact + trigger="compact" + panelClassName="min-w-0" options={[ { value: "az", label: "A-Z" }, { value: "za", label: "Z-A" }, diff --git a/src/components/ThemedDropdown.tsx b/src/components/ThemedDropdown.tsx deleted file mode 100644 index db8a833..0000000 --- a/src/components/ThemedDropdown.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -export interface DropdownOption { - value: string; - label: string; -} - -export function ThemedDropdown({ - value, - options, - onChange, - ariaLabel, - compact = false, - align = "right", -}: { - value: string; - options: DropdownOption[]; - onChange: (value: string) => void; - ariaLabel: string; - compact?: boolean; - align?: "left" | "right"; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const current = options.find((option) => option.value === value) ?? options[0]; - - useEffect(() => { - const handlePointerDown = (event: PointerEvent) => { - if (!ref.current?.contains(event.target as Node)) setOpen(false); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - window.addEventListener("pointerdown", handlePointerDown); - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("pointerdown", handlePointerDown); - window.removeEventListener("keydown", handleKeyDown); - }; - }, []); - - return ( -
- - - {open ? ( -
- {options.map((option) => { - const selected = option.value === value; - return ( - - ); - })} -
- ) : null} -
- ); -} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 16b2d0b..76d9c8d 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ColorFilter } from "./ColorFilter"; +import { Dropdown, useDismissable } from "./menu"; import { Tooltip } from "./Tooltip"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ @@ -30,71 +31,6 @@ function getSortOptions(mediaFilter: MediaFilter) { return BASE_SORT_OPTIONS; } -function SortDropdown({ - value, - onChange, - options, -}: { - value: SortOrder; - onChange: (v: SortOrder) => void; - options: { value: SortOrder; label: string }[]; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const current = options.find((o) => o.value === value) ?? BASE_SORT_OPTIONS[0]; - - useEffect(() => { - const close = (e: MouseEvent) => { - if (!ref.current?.contains(e.target as Node)) setOpen(false); - }; - window.addEventListener("pointerdown", close); - return () => window.removeEventListener("pointerdown", close); - }, []); - - return ( -
- - {open ? ( -
- {options.map((option) => ( - - ))} -
- ) : null} -
- ); -} - function FilterPill({ label, active, @@ -253,14 +189,7 @@ export function Toolbar() { return () => window.removeEventListener("keydown", handleKeyDown); }, [clearSearch, hasActiveSearch]); - useEffect(() => { - const close = (event: PointerEvent) => { - if (searchShellRef.current?.contains(event.target as Node)) return; - setSearchPanelOpen(false); - }; - window.addEventListener("pointerdown", close); - return () => window.removeEventListener("pointerdown", close); - }, []); + useDismissable(searchShellRef, () => setSearchPanelOpen(false)); const showTagSuggestions = searchCommand === "tag" && searchPanelOpen; const showCommandHints = !searchCommand && searchPanelOpen; @@ -431,7 +360,15 @@ export function Toolbar() {
{/* Sort */} - + {/* Divider */}
diff --git a/src/components/menu/Dropdown.tsx b/src/components/menu/Dropdown.tsx new file mode 100644 index 0000000..1832108 --- /dev/null +++ b/src/components/menu/Dropdown.tsx @@ -0,0 +1,136 @@ +import { ReactNode, useRef, useState } from "react"; +import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from "./Menu"; +import { useDismissable } from "./useDismissable"; +import { Tooltip } from "../Tooltip"; + +export interface DropdownOption { + value: T; + label: string; + /** Trailing detail on the option row (e.g. an item count). */ + hint?: ReactNode; +} + +const TRIGGER_STYLES = { + // Filled, bordered select — settings forms and panel headers. + solid: { + base: "min-w-40 gap-2 rounded-md border px-3 py-1.5 text-xs border-white/10 bg-white/[0.055] light-theme:border-gray-700/50 light-theme:bg-gray-900", + open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", + closed: + "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", + }, + // Transparent until engaged — toolbar controls (sort, folder scope). + ghost: { + base: "gap-1.5 rounded-lg border px-3 py-1.5 text-xs", + open: "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white", + closed: + "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white", + }, + // Tiny inline picker — sidebar section headers. + compact: { + base: "gap-2 rounded-md border px-1.5 py-0.5 text-[10px] font-medium border-white/10 bg-white/[0.04] light-theme:border-gray-700/50 light-theme:bg-gray-900", + open: "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white", + closed: + "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white", + }, +} as const; + +/** + * The app-wide select control: a trigger button plus a MenuPanel of options. + * Replaces the former ThemedDropdown / SortDropdown / FolderScopeDropdown + * trio. Options are compared to `value` with Object.is, so number and + * null values (folder scopes) work as well as string unions. + */ +export function Dropdown({ + value, + options, + onChange, + ariaLabel, + align = "right", + trigger = "solid", + size = "sm", + triggerIcon, + triggerTooltip, + triggerClassName = "", + panelClassName = "", +}: { + value: T; + options: DropdownOption[]; + onChange: (value: T) => void; + ariaLabel: string; + align?: "left" | "right"; + trigger?: keyof typeof TRIGGER_STYLES; + size?: MenuSize; + triggerIcon?: ReactNode; + triggerTooltip?: string; + triggerClassName?: string; + panelClassName?: string; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const current = options.find((option) => Object.is(option.value, value)) ?? options[0]; + const style = TRIGGER_STYLES[trigger]; + + useDismissable(ref, () => setOpen(false), open); + + const button = ( + + ); + + return ( +
+ {triggerTooltip ? ( + + {button} + + ) : ( + button + )} + {open ? ( +
+ setOpen(false)}> + + {options.map((option) => { + const selected = Object.is(option.value, value); + return ( + onChange(option.value)} + /> + ); + })} + + +
+ ) : null} +
+ ); +} diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index ae44ab5..049d2d7 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -14,7 +14,7 @@ export type MenuSize = "sm" | "md"; export const MenuCloseContext = createContext<() => void>(() => {}); const MenuSizeContext = createContext("md"); -function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { +function itemClass(size: MenuSize, danger: boolean, disabled: boolean, active = false): string { const base = size === "sm" ? "w-full rounded-md px-3 py-1.5 text-[12px]" @@ -23,8 +23,16 @@ function itemClass(size: MenuSize, danger: boolean, disabled: boolean): string { ? "text-gray-600 cursor-not-allowed" : danger ? "text-red-400 hover:bg-red-500/15 hover:text-red-300" - : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; - return `${base} ${tone} transition-colors`; + : active + ? "bg-white/[0.08] text-white" + : "text-gray-200 hover:bg-white/[0.06] hover:text-white"; + // menu-item + data attributes are the stable hooks the subtle-light theme + // targets in index.css — keep them if the utility classes change. + return `menu-item ${base} ${tone} transition-colors`; +} + +function itemTone(danger: boolean, disabled: boolean): "danger" | "disabled" | "default" { + return danger ? "danger" : disabled ? "disabled" : "default"; } /** @@ -42,10 +50,18 @@ export function MenuPanel({ }) { const inherited = useContext(MenuSizeContext); const resolved = size ?? inherited; + // Default min-width steps aside when the caller sets its own width class — + // Tailwind resolves competing min-w utilities by stylesheet order, not + // className order, so merging both would be unpredictable. + const widthClass = /(^|\s)(min-w-|w-)/.test(className) + ? "" + : resolved === "sm" + ? "min-w-40" + : "min-w-52"; return (
{children}
@@ -58,27 +74,36 @@ export function MenuItem({ onSelect, danger = false, disabled = false, + active = false, checked, hint, keepOpen = false, + role, }: { label: ReactNode; onSelect?: () => void; danger?: boolean; disabled?: boolean; + /** Highlights the row as the current choice (dropdown selections, active view). */ + active?: boolean; /** Renders a trailing check mark; use for exclusive-choice menus (themes, sort orders). */ checked?: boolean; hint?: ReactNode; /** Skip the automatic menu close after selecting. */ keepOpen?: boolean; + role?: React.AriaRole; }) { const size = useContext(MenuSizeContext); const close = useContext(MenuCloseContext); return ( - ); -} - -function MenuPanel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -function MenuItem({ - label, - hint, - active = false, - onClick, -}: { - label: string; - hint?: string; - active?: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -const ZOOM_OPTIONS: { value: ZoomPreset; label: string }[] = [ - { value: "compact", label: "Compact Grid" }, - { value: "comfortable", label: "Comfortable Grid" }, - { value: "detail", label: "Detail Grid" }, -]; - -const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [ - { value: "all", label: "All Media" }, - { value: "image", label: "Images" }, - { value: "video", label: "Videos" }, -]; - -export function MenuBar() { - const [openMenu, setOpenMenu] = useState(null); - const rootRef = useRef(null); - const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); - const reindexFolder = useGalleryStore((state) => state.reindexFolder); - const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); - const zoomPreset = useGalleryStore((state) => state.zoomPreset); - const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); - const mediaFilter = useGalleryStore((state) => state.mediaFilter); - const setMediaFilter = useGalleryStore((state) => state.setMediaFilter); - const favoritesOnly = useGalleryStore((state) => state.favoritesOnly); - const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly); - - useEffect(() => { - const handlePointerDown = (event: MouseEvent) => { - if (!rootRef.current?.contains(event.target as Node)) { - setOpenMenu(null); - } - }; - - window.addEventListener("pointerdown", handlePointerDown); - return () => window.removeEventListener("pointerdown", handlePointerDown); - }, []); - - const handleAddFolder = () => { - setFolderPickerOpen(true); - setOpenMenu(null); - }; - - const handleReindex = async () => { - if (selectedFolderId !== null) { - await reindexFolder(selectedFolderId); - } - setOpenMenu(null); - }; - - return ( -
-
- setOpenMenu((current) => (current === "library" ? null : "library"))} - /> - {openMenu === "library" ? ( - - - - - ) : null} -
- -
- setOpenMenu((current) => (current === "view" ? null : "view"))} - /> - {openMenu === "view" ? ( - - {ZOOM_OPTIONS.map((option) => ( - { - setZoomPreset(option.value); - setOpenMenu(null); - }} - /> - ))} - - ) : null} -
- -
- setOpenMenu((current) => (current === "filter" ? null : "filter"))} - /> - {openMenu === "filter" ? ( - - {FILTER_OPTIONS.map((option) => ( - { - setMediaFilter(option.value); - setOpenMenu(null); - }} - /> - ))} -
- { - setFavoritesOnly(!favoritesOnly); - setOpenMenu(null); - }} - /> - - ) : null} -
-
- ); -} From 680670336322d57fa824e38045034dc8caed2529 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 12:11:00 +0100 Subject: [PATCH 07/27] fix(dev): clone mock IPC results to match real invoke semantics Mock handlers return references straight into the in-memory db (e.g. `return db.albums`), so store updates like set({ albums }) kept the same array identity across loads and Zustand never notified subscribers -- the sidebar album list froze after creating an album from the bulk bar. Real invoke() deserializes fresh JSON per call, so production was never affected. structuredClone in the shim restores that fidelity for every mock command at once. --- src/dev/setupMockTauri.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/dev/setupMockTauri.ts b/src/dev/setupMockTauri.ts index c566de0..8525c74 100644 --- a/src/dev/setupMockTauri.ts +++ b/src/dev/setupMockTauri.ts @@ -4,8 +4,19 @@ import { handleMockCommand } from "./mockBackend"; mockWindows("main"); mockConvertFileSrc("windows"); -mockIPC((cmd, payload) => handleMockCommand(cmd, payload), { - shouldMockEvents: true, -}); +mockIPC( + async (cmd, payload) => { + const result = await handleMockCommand(cmd, payload); + // Real invoke() deserializes fresh JSON on every call, so callers never + // share references into backend state and Zustand identity checks see + // every change. Clone here so the mock behaves the same — returning + // `db.albums` directly froze the sidebar because set({ albums }) kept + // the same array identity across loads. + return structuredClone(result); + }, + { + shouldMockEvents: true, + }, +); console.info("[Phokus UI Lab] Mock Tauri backend installed."); From ee2a1b204e470df36f3fb7c9a2c111ba56cc241d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 12:11:26 +0100 Subject: [PATCH 08/27] refactor(sidebar): extract InlineRename, InlineConfirm, and NavItem FolderItem and AlbumItem each carried their own copy of the in-place rename input (state, select-on-open effect, commit/cancel handling) and the Confirm/Cancel pair for destructive actions. Both are now shared components: InlineRename mounts fresh per rename (an external name change mid-rename no longer clobbers typing), InlineConfirm is the compact red/gray pair. The four copy-pasted nav rows collapse into a local NavItem component. --- src/components/InlineConfirm.tsx | 28 ++++ src/components/InlineRename.tsx | 50 ++++++++ src/components/Sidebar.tsx | 213 ++++++++++--------------------- 3 files changed, 142 insertions(+), 149 deletions(-) create mode 100644 src/components/InlineConfirm.tsx create mode 100644 src/components/InlineRename.tsx diff --git a/src/components/InlineConfirm.tsx b/src/components/InlineConfirm.tsx new file mode 100644 index 0000000..7e4b5f4 --- /dev/null +++ b/src/components/InlineConfirm.tsx @@ -0,0 +1,28 @@ +/** + * Compact Confirm/Cancel pair for destructive row actions (remove folder, + * delete album). Swap it in where the hover actions normally sit. + */ +export function InlineConfirm({ + onConfirm, + onCancel, +}: { + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
event.stopPropagation()}> + + +
+ ); +} diff --git a/src/components/InlineRename.tsx b/src/components/InlineRename.tsx new file mode 100644 index 0000000..ce873da --- /dev/null +++ b/src/components/InlineRename.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * In-place rename input for sidebar rows (folders, albums). Mount it in + * place of the row label while renaming: commits on Enter or blur (only when + * the trimmed name is non-empty and actually changed), cancels on Escape. + */ +export function InlineRename({ + name, + onRename, + onClose, +}: { + name: string; + onRename: (next: string) => Promise | void; + onClose: () => void; +}) { + const [value, setValue] = useState(name); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const commit = async () => { + const trimmed = value.trim(); + if (trimmed && trimmed !== name) { + await onRename(trimmed); + } + onClose(); + }; + + return ( + setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commit(); + } + if (event.key === "Escape") onClose(); + }} + onBlur={() => void commit()} + onClick={(event) => event.stopPropagation()} + /> + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a9f821c..83cfe5f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -3,12 +3,40 @@ import { Reorder, useDragControls } from "framer-motion"; import { open } from "@tauri-apps/plugin-dialog"; import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu"; +import { InlineConfirm } from "./InlineConfirm"; +import { InlineRename } from "./InlineRename"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; type LibrarySort = "az" | "za" | "custom"; const LIBRARY_SORT_KEY = "phokus-library-sort"; +function NavItem({ + label, + iconPath, + active, + onClick, +}: { + label: string; + iconPath: string; + active: boolean; + onClick: () => void; +}) { + return ( +
+ + + + {label} +
+ ); +} + function FolderItem({ folder, selected, @@ -42,16 +70,7 @@ function FolderItem({ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(folder.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); - const renameInputRef = useRef(null); - - useEffect(() => { - if (renaming) { - setRenameValue(folder.name); - setTimeout(() => renameInputRef.current?.select(), 0); - } - }, [renaming, folder.name]); const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); @@ -66,19 +85,6 @@ function FolderItem({ } }; - const commitRename = async () => { - const trimmed = renameValue.trim(); - if (trimmed && trimmed !== folder.name) { - await renameFolder(folder.id, trimmed); - } - setRenaming(false); - }; - - const handleRenameKey = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { e.preventDefault(); void commitRename(); } - if (e.key === "Escape") { setRenaming(false); } - }; - return ( {renaming ? ( - setRenameValue(e.target.value)} - onKeyDown={handleRenameKey} - onBlur={() => void commitRename()} - onClick={(e) => e.stopPropagation()} + renameFolder(folder.id, next)} + onClose={() => setRenaming(false)} /> ) : (
@@ -180,20 +182,10 @@ function FolderItem({ {/* Hover action buttons */} {!renaming && ( confirmingRemoval ? ( -
e.stopPropagation()}> - - -
+ { void removeFolder(folder.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> ) : (
@@ -294,24 +286,7 @@ function AlbumItem({ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); const [renaming, setRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(album.name); const [confirmingRemoval, setConfirmingRemoval] = useState(false); - const renameInputRef = useRef(null); - - useEffect(() => { - if (renaming) { - setRenameValue(album.name); - setTimeout(() => renameInputRef.current?.select(), 0); - } - }, [renaming, album.name]); - - const commitRename = async () => { - const trimmed = renameValue.trim(); - if (trimmed && trimmed !== album.name) { - await renameAlbum(album.id, trimmed); - } - setRenaming(false); - }; const cover = mediaSrc(album.cover_thumbnail_path); @@ -404,17 +379,10 @@ function AlbumItem({
{renaming ? ( - setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); void commitRename(); } - if (e.key === "Escape") setRenaming(false); - }} - onBlur={() => void commitRename()} - onClick={(e) => e.stopPropagation()} + renameAlbum(album.id, next)} + onClose={() => setRenaming(false)} /> ) : (
@@ -425,20 +393,10 @@ function AlbumItem({
{!renaming && confirmingRemoval ? ( -
e.stopPropagation()}> - - -
+ { void deleteAlbum(album.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> ) : null} {menu ? ( @@ -714,73 +672,30 @@ export function Sidebar() { {/* Nav */}
-
selectFolder(null)} - > - - - - - All Media - -
- -
+ setView("explore")} - > - - - - - Explore - -
- -
+ setView("timeline")} - > - - - - - Timeline - -
- -
+ setView("duplicates")} - > - - - - - Duplicates - -
+ iconPath="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" + />
{/* Section label */} From c27662dd744baad4794c6a507b4f8a38da9d0ded Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 12:11:36 +0100 Subject: [PATCH 09/27] refactor(ui): extract AlbumPicker from BulkActionBar The album popover body (album list + create-new-album form) moves to its own component. The host supplies onPick, which both an existing album row and a freshly created album route through -- BulkActionBar adds the selection and closes the panel. Ready for reuse anywhere else an album needs picking (e.g. the context menu submenu later). --- src/components/AlbumPicker.tsx | 70 ++++++++++++++++++++++++++++++++ src/components/BulkActionBar.tsx | 63 ++-------------------------- 2 files changed, 73 insertions(+), 60 deletions(-) create mode 100644 src/components/AlbumPicker.tsx diff --git a/src/components/AlbumPicker.tsx b/src/components/AlbumPicker.tsx new file mode 100644 index 0000000..9b95445 --- /dev/null +++ b/src/components/AlbumPicker.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { useGalleryStore } from "../store"; + +/** + * Album list plus a create-new-album form. The host decides what picking + * means — the bulk bar adds the current selection; a newly created album is + * picked immediately. + */ +export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise | void }) { + const albums = useGalleryStore((state) => state.albums); + const createAlbum = useGalleryStore((state) => state.createAlbum); + const [creating, setCreating] = useState(false); + const [newAlbumName, setNewAlbumName] = useState(""); + + const handleCreate = async () => { + const name = newAlbumName.trim(); + if (!name || creating) return; + setCreating(true); + try { + const album = await createAlbum(name); + setNewAlbumName(""); + await onPick(album.id); + } finally { + setCreating(false); + } + }; + + return ( + <> +
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + event.preventDefault(); + void handleCreate(); + }} + > + setNewAlbumName(event.target.value)} + disabled={creating} + /> + +
+ + ); +} diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index a0340f5..d563bbe 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useGalleryStore } from "../store"; +import { AlbumPicker } from "./AlbumPicker"; import { BulkTagPopover } from "./bulk/BulkTagPopover"; import { useDismissable } from "./menu"; import { Tooltip } from "./Tooltip" @@ -18,15 +19,11 @@ export function BulkActionBar() { const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected); const activeView = useGalleryStore((state) => state.activeView); const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); - const albums = useGalleryStore((state) => state.albums); const addToAlbum = useGalleryStore((state) => state.addToAlbum); const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum); - const createAlbum = useGalleryStore((state) => state.createAlbum); const [panel, setPanel] = useState(null); const [deleting, setDeleting] = useState(false); - const [creatingAlbum, setCreatingAlbum] = useState(false); - const [newAlbumName, setNewAlbumName] = useState(""); const barRef = useRef(null); // Close any open popover when clicking outside the bar or pressing Escape. @@ -34,10 +31,7 @@ export function BulkActionBar() { // Reset transient UI whenever the selection empties. useEffect(() => { - if (selectedCount === 0) { - setPanel(null); - setNewAlbumName(""); - } + if (selectedCount === 0) setPanel(null); }, [selectedCount]); if (selectedCount === 0) return null; @@ -61,20 +55,6 @@ export function BulkActionBar() { setPanel(null); }; - const handleCreateAlbum = async () => { - const name = newAlbumName.trim(); - if (!name || creatingAlbum) return; - setCreatingAlbum(true); - try { - const album = await createAlbum(name); - await addToAlbum(album.id, ids); - setNewAlbumName(""); - setPanel(null); - } finally { - setCreatingAlbum(false); - } - }; - const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors"; const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`; const btnActive = `${btn} bg-white/10 text-white`; @@ -162,44 +142,7 @@ export function BulkActionBar() { data-bulk-popover className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur" > -
- {albums.length === 0 ? ( -

No albums yet — create one below.

- ) : ( - albums.map((album) => ( - - )) - )} -
-
{ - event.preventDefault(); - void handleCreateAlbum(); - }} - > - setNewAlbumName(event.target.value)} - disabled={creatingAlbum} - /> - -
+
) : null}
From 4d41f3744ff87ed752225c04f86aff3bd59226a0 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 12:21:04 +0100 Subject: [PATCH 10/27] fix(duplicates): key duplicate tiles on the outermost mapped element The key sat on the button nested inside the Tooltip wrapper, so React warned about missing keys for every duplicate group tile. --- src/components/DuplicateFinder.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 5d9fc26..e228048 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -71,9 +71,8 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { const isSelected = selectedIds.has(image.id); const src = mediaSrc(image.thumbnail_path); return ( - + )} @@ -518,9 +507,7 @@ export function BackgroundTasks() { onClick={() => toggleWorker(task.id, workerKey)} > {isPaused ? ( - - - + ) : ( @@ -575,9 +562,7 @@ export function BackgroundTasks() { className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" onClick={() => dismissTask(task.id, task.snapshot)} > - - - + )} diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index d563bbe..e3e480f 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -4,6 +4,7 @@ import { AlbumPicker } from "./AlbumPicker"; import { BulkTagPopover } from "./bulk/BulkTagPopover"; import { useDismissable } from "./menu"; import { Tooltip } from "./Tooltip" +import { CloseIcon, StarIcon, WarningIcon } from "./icons"; type Panel = "tag" | "rating" | "album" | "delete" | null; @@ -109,9 +110,7 @@ export function BulkActionBar() { setPanel(null); }} > - - - + ); @@ -174,10 +173,7 @@ export function BulkActionBar() { className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur" >
- - - +

Delete from disk

@@ -207,9 +203,7 @@ export function BulkActionBar() { className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" onClick={clearGallerySelection} > - - - +

diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index e228048..a6b546d 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -4,6 +4,7 @@ import { DuplicateGroup, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; +import { WarningIcon } from "./icons"; function formatBytes(bytes: number): string { if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; @@ -240,10 +241,7 @@ export function DuplicateFinder() {
setConfirmingDelete(false)} />
- - - +

Delete from disk

diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index a437c97..467b9fd 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; +import { CheckIcon, ChevronRightIcon, CloseIcon } from "./icons"; function normalizePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); @@ -111,9 +112,7 @@ function FolderRow({ disabled={alreadyAdded} aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`} > - - - + @@ -141,9 +140,7 @@ function FolderRow({ className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" onClick={onNavigate} > - - - +

@@ -203,9 +200,7 @@ function StagedFoldersPanel({ onClick={() => onRemove(path)} aria-label={`Remove ${path} from folders to add`} > - - - +
@@ -415,9 +410,7 @@ export function FolderPickerModal() { className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" onClick={() => setFolderPickerOpen(false)} > - - - +
diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index fd70c86..2160fae 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -5,6 +5,7 @@ import { BulkActionBar } from "./BulkActionBar"; import { ImageContextMenu } from "./ImageContextMenu"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; +import { CheckIcon, PhotoIcon, PlayIcon, StarIcon, WarningIcon } from "./icons"; const GAP = 6; @@ -85,9 +86,7 @@ export function ImageTile({ : "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100" }`} > - - - +
{/* Image / placeholder */} @@ -108,14 +107,9 @@ export function ImageTile({ ) : (
{image.media_kind === "video" ? ( - - - + ) : ( - - - + )}
)} @@ -124,9 +118,7 @@ export function ImageTile({ {image.media_kind === "video" && (
- - - +
)} @@ -136,10 +128,7 @@ export function ImageTile({ {image.embedding_status === "failed" && (
- - - +
)} @@ -153,9 +142,7 @@ export function ImageTile({ {image.rating > 0 && (
{Array.from({ length: image.rating }, (_, index) => ( - - - + ))}
)} @@ -176,9 +163,7 @@ export function ImageTile({ {image.rating > 0 ? (
{Array.from({ length: image.rating }, (_, i) => ( - - - + ))}
) : ( @@ -338,10 +323,7 @@ export function Gallery() { ) : images.length === 0 && !loadingImages ? (
- - - +

{imageLoadError ? "Could not load results" diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx index 24dc822..086ea79 100644 --- a/src/components/ImageContextMenu.tsx +++ b/src/components/ImageContextMenu.tsx @@ -1,6 +1,7 @@ import { ImageRecord, useGalleryStore } from "../store"; import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from "./menu"; import { Tooltip } from "./Tooltip"; +import { CloseIcon, StarIcon } from "./icons"; /** Right-click menu for an image tile. Shared by the Gallery grid and the Timeline. */ export function ImageContextMenu({ @@ -58,12 +59,7 @@ export function ImageContextMenu({ className="rounded-md p-1 transition-colors hover:bg-white/5" onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }} > - - - + ); @@ -74,9 +70,7 @@ export function ImageContextMenu({ className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors" onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }} > - - - + ) : null} diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 22c4730..977c024 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -6,6 +6,7 @@ import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; import { VideoPlayer } from "./VideoPlayer"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; +import { ChevronRightIcon, CloseIcon, StarIcon } from "./icons"; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -751,9 +752,7 @@ export function Lightbox() { exitSlideshow(); }} > - - - + @@ -811,9 +810,7 @@ export function Lightbox() { goSlideshow(1); }} > - - - +

@@ -1006,9 +1003,7 @@ export function Lightbox() {
@@ -1042,9 +1037,7 @@ export function Lightbox() { ) : regionSelectMode ? ( - - - + Cancel selection ) : ( @@ -1074,13 +1067,7 @@ export function Lightbox() { className="rounded-md p-1" onClick={() => void updateImageDetails(selectedImage.id, { rating })} > - - - + ); @@ -1091,9 +1078,7 @@ export function Lightbox() { className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white" onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })} > - - - + ) : null} @@ -1211,9 +1196,7 @@ export function Lightbox() { ); }} > - - - + @@ -1426,9 +1409,7 @@ export function Lightbox() { goNext(); }} > - - - + )} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index cb6ed6c..4a67201 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -5,6 +5,7 @@ import { Dropdown } from "./menu"; import { getChangelogForVersion } from "../changelog"; import { Tooltip } from "./Tooltip"; import { TAGGER_MODELS } from "../taggerModels"; +import { CloseIcon } from "./icons"; type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace"; @@ -440,9 +441,7 @@ export function SettingsModal() { className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" onClick={() => setSettingsOpen(false)} > - - - +
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 83cfe5f..465860a 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { InlineConfirm } from "./InlineConfirm"; import { InlineRename } from "./InlineRename"; import { mediaSrc } from "../lib/mediaSrc"; import { Tooltip } from "./Tooltip"; +import { CheckIcon, CloseIcon, FolderIcon, PhotoIcon, PlusIcon } from "./icons"; type LibrarySort = "az" | "za" | "custom"; const LIBRARY_SORT_KEY = "phokus-library-sort"; @@ -146,10 +147,7 @@ function FolderItem({ ) : ( - - - + )}
@@ -204,10 +202,7 @@ function FolderItem({ className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors" onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }} > - - - +
@@ -334,9 +329,7 @@ function AlbumItem({ selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent" }`} > - - - +
) : null} @@ -369,10 +362,7 @@ function AlbumItem({ ) : (
- - - +
)}
@@ -663,9 +653,7 @@ export function Sidebar() { onClick={handleAddFolder} className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors" > - - - + @@ -786,9 +774,7 @@ export function Sidebar() { onClick={startCreatingAlbum} className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200" > - - - + diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 76d9c8d..30a0cfe 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -5,6 +5,7 @@ import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ColorFilter } from "./ColorFilter"; import { Dropdown, useDismissable } from "./menu"; import { Tooltip } from "./Tooltip"; +import { CloseIcon } from "./icons"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, @@ -279,9 +280,7 @@ export function Toolbar() { clearSearch(); }} > - - - + diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx index 62d9eca..eb00071 100644 --- a/src/components/WhatsNewModal.tsx +++ b/src/components/WhatsNewModal.tsx @@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useGalleryStore } from "../store"; import { getChangelogForVersion, type ChangelogSection } from "../changelog"; import { Tooltip } from "./Tooltip"; +import { ChevronRightIcon, CloseIcon } from "./icons"; // Shown in the tooltip so the user can see the link's destination before // clicking. The actual navigation is handled by the open_changelog_url command. @@ -69,14 +70,7 @@ function Section({ section }: { section: ChangelogSection }) { className="flex w-full items-center gap-2 text-left" aria-expanded={open} > - - - + {section.title} {section.items.length} @@ -161,9 +155,7 @@ export function WhatsNewModal() { className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" onClick={closeWhatsNew} > - - - + diff --git a/src/components/bulk/BulkTagFields.tsx b/src/components/bulk/BulkTagFields.tsx index b186d5b..afd66e2 100644 --- a/src/components/bulk/BulkTagFields.tsx +++ b/src/components/bulk/BulkTagFields.tsx @@ -1,5 +1,6 @@ import { useBulkTagEditor } from "./useBulkTagEditor"; import { Tooltip } from "../Tooltip"; +import { CloseIcon } from "../icons"; // Presentational tag-editing fields shared by the popover and modal surfaces. export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) { @@ -61,9 +62,7 @@ export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) { className="text-gray-600 transition-colors hover:text-white" onClick={() => void removeTag(tag)} > - - - + diff --git a/src/components/bulk/BulkTagPopover.tsx b/src/components/bulk/BulkTagPopover.tsx index 4d28e99..5d92da1 100644 --- a/src/components/bulk/BulkTagPopover.tsx +++ b/src/components/bulk/BulkTagPopover.tsx @@ -1,5 +1,6 @@ import { BulkTagFields } from "./BulkTagFields"; import { Tooltip } from "../Tooltip"; +import { CloseIcon } from "../icons"; // Inline popover surface for bulk tagging — the default editing surface. // Anchored above the bar by the parent; closes on outside click via the @@ -18,9 +19,7 @@ export function BulkTagPopover({ onClose }: { onClose: () => void }) { className="text-gray-600 transition-colors hover:text-white" onClick={onClose} > - - - + diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..b57b549 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,60 @@ +/** + * Shared icons for paths that repeat across the app. One-off icons stay + * inline at their call site — only extract here once a shape shows up in + * three or more places. Stroke icons take a per-site strokeWidth because + * weights legitimately differ by context (menus vs badges vs empty states). + */ + +export interface IconProps { + className?: string; + strokeWidth?: number; +} + +function strokeIcon(d: string, defaultStrokeWidth: number, displayName: string) { + function Icon({ className = "", strokeWidth = defaultStrokeWidth }: IconProps) { + return ( + + ); + } + Icon.displayName = displayName; + return Icon; +} + +export const CheckIcon = strokeIcon("M5 13l4 4L19 7", 2.5, "CheckIcon"); +export const CloseIcon = strokeIcon("M6 18L18 6M6 6l12 12", 2, "CloseIcon"); +export const ChevronDownIcon = strokeIcon("M19 9l-7 7-7-7", 2, "ChevronDownIcon"); +export const ChevronRightIcon = strokeIcon("M9 5l7 7-7 7", 2, "ChevronRightIcon"); +export const PlusIcon = strokeIcon("M12 4v16m8-8H4", 1.75, "PlusIcon"); +export const PhotoIcon = strokeIcon( + "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", + 1.5, + "PhotoIcon", +); +export const FolderIcon = strokeIcon( + "M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z", + 1.5, + "FolderIcon", +); +export const WarningIcon = strokeIcon( + "M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z", + 2, + "WarningIcon", +); + +export function StarIcon({ className = "" }: { className?: string }) { + return ( + + ); +} + +export function PlayIcon({ className = "" }: { className?: string }) { + return ( + + ); +} diff --git a/src/components/menu/Dropdown.tsx b/src/components/menu/Dropdown.tsx index 1832108..c889076 100644 --- a/src/components/menu/Dropdown.tsx +++ b/src/components/menu/Dropdown.tsx @@ -2,6 +2,7 @@ import { ReactNode, useRef, useState } from "react"; import { MenuCloseContext, MenuItem, MenuPanel, MenuSize } from "./Menu"; import { useDismissable } from "./useDismissable"; import { Tooltip } from "../Tooltip"; +import { ChevronDownIcon } from "../icons"; export interface DropdownOption { value: T; @@ -85,14 +86,7 @@ export function Dropdown({ > {triggerIcon} {current?.label} - - - + ); diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index 049d2d7..4143a4b 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -7,6 +7,7 @@ import { useRef, useState, } from "react"; +import { CheckIcon, ChevronRightIcon } from "../icons"; export type MenuSize = "sm" | "md"; @@ -115,9 +116,7 @@ export function MenuItem({ {hint} ) : null} {checked ? ( - - - + ) : null} ); @@ -206,9 +205,7 @@ export function SubMenu({ onClick={openNow} > {label} - - - + {open ? (
Date: Sat, 4 Jul 2026 13:16:38 +0100 Subject: [PATCH 12/27] refactor(store): split store.ts into feature slices under src/store/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 3,244-line monolithic store into a Zustand slice-per-feature layout (types, helpers, librarySlice, gallerySlice, searchSlice, exploreSlice, albumSlice, duplicateSlice, taggerSlice, captionSlice, settingsSlice, appSlice, events) combined in index.ts. Components keep calling useGalleryStore(s => s.field) against the same flat state object — no component changes required. Completes the de-godify effort started with the menu/Dropdown/Sidebar extractions and icons.tsx. --- CHANGELOG.md | 12 +- CLAUDE.md | 6 +- src/store.ts | 3243 ----------------------------------- src/store/albumSlice.ts | 161 ++ src/store/appSlice.ts | 320 ++++ src/store/captionSlice.ts | 185 ++ src/store/duplicateSlice.ts | 142 ++ src/store/events.ts | 355 ++++ src/store/exploreSlice.ts | 282 +++ src/store/gallerySlice.ts | 522 ++++++ src/store/helpers.ts | 249 +++ src/store/index.ts | 49 + src/store/librarySlice.ts | 173 ++ src/store/searchSlice.ts | 258 +++ src/store/settingsSlice.ts | 172 ++ src/store/taggerSlice.ts | 283 +++ src/store/types.ts | 327 ++++ 17 files changed, 3486 insertions(+), 3253 deletions(-) delete mode 100644 src/store.ts create mode 100644 src/store/albumSlice.ts create mode 100644 src/store/appSlice.ts create mode 100644 src/store/captionSlice.ts create mode 100644 src/store/duplicateSlice.ts create mode 100644 src/store/events.ts create mode 100644 src/store/exploreSlice.ts create mode 100644 src/store/gallerySlice.ts create mode 100644 src/store/helpers.ts create mode 100644 src/store/index.ts create mode 100644 src/store/librarySlice.ts create mode 100644 src/store/searchSlice.ts create mode 100644 src/store/settingsSlice.ts create mode 100644 src/store/taggerSlice.ts create mode 100644 src/store/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cc5cb..3f8cebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) an album can now stay inside that album, jump back to the source folder, or search everything. - **Tag manager** — Explore's Tag Cloud now has a Manage mode for renaming, - merging, and deleting tags across the whole library. There is also an "Open tag - manager" shortcut in Settings -> AI Workspace when you want to get straight to - the cleanup. + merging, and deleting tags across the whole library. - **Camera info in the lightbox** — the info panel now shows EXIF details like camera, lens, aperture, shutter speed, ISO, and focal length. Geotagged photos also get a browser link for their GPS coordinates, and already-indexed images @@ -43,7 +41,6 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Choose your tagging model** — Settings -> AI Workspace now lets you pick between the anime-focused WD tagger and JoyTag, which is better suited to photo libraries and stronger on NSFW concepts (if that's your thing, we don't judge). - Models download only when needed, and tags remember which model created them. - **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags that most often appear with it, complete with connection lines and image counts. Handy for finding little clusters you did not know were there. @@ -53,9 +50,7 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - **Editable folder path** — the folder picker now has an address bar, so you can paste a path directly while still using breadcrumbs for quick jumps. - **Slideshow mode** — turn the lightbox into a fullscreen, image-only slideshow - from whatever collection you are already browsing. Videos are skipped, controls - tuck themselves away after a few seconds, and Settings lets you pick the pace - and whether playback follows the current order or goes random. + from whatever collection you are already browsing. - **Add to album from the right-click menu** — right-click any image in the Gallery or Timeline and file it straight into an album from the new "Add to Album" submenu. One image, one click, zero ceremony. @@ -131,6 +126,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) cleaned up on startup. Your manually-added tags are left alone. - **Selected Folders starts empty** — choosing "Selected Folders" for AI tagging no longer pre-selects the first folder. You decide exactly what gets queued. +- **A couple of tiny UI papercuts are gone** — the zoom buttons now show the + right tile size when you hover them, and the folder picker no longer adds an + odd trailing slash to Windows drive breadcrumbs. ## [0.1.1] — 2026-06-23 diff --git a/CLAUDE.md b/CLAUDE.md index 5546559..dcd4f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ There are no test suites configured. ### Frontend (`src/`) -- **`store.ts`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend. Every feature (folders, images, search, similar images, tags, captions, tagger, duplicates) is implemented as store actions here. React components are thin consumers. +- **`src/store/`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend, split into per-feature slices combined in `index.ts` (which exports `useGalleryStore` and the `GalleryStore` type). `types.ts` holds all interfaces/type unions (including `ImageRecord`); `helpers.ts` holds pure functions and cross-slice module state (search parsing, image sort/merge, request-token guards). Slices: `librarySlice` (folders), `gallerySlice` (image paging/filters/bulk actions), `searchSlice` (search + similar-images), `exploreSlice` (visual clusters, tag cloud, tags), `albumSlice`, `duplicateSlice`, `taggerSlice`, `captionSlice`, `settingsSlice`, `appSlice` (updates, onboarding, ffmpeg, worker pauses); `events.ts` wires the Tauri event listeners (`subscribeToProgress`). Components still call `useGalleryStore(s => s.field)` against one flat state object — the slice split is internal. React components are thin consumers. - **`App.tsx`** — sets up Tauri event listeners (`subscribeToProgress`) and renders the top-level layout (sidebar + active view). - **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `Timeline`, `ExploreView`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `TitleBar`. - **`src/components/menu/`** — shared floating-UI primitives: `useDismissable`, `MenuPanel`/`MenuItem`/`SubMenu`, the portal-based `ContextMenu`, and the app-wide `Dropdown`. Build menus, dropdowns, and popovers on these instead of hand-rolling. @@ -44,7 +44,7 @@ There are no test suites configured. ### Search modes -The search bar supports prefix syntax parsed by `parseSearchValue` in `store.ts`: +The search bar supports prefix syntax parsed by `parseSearchValue` in `src/store/helpers.ts`: - No prefix / `f:` — filename search (paginated, DB-backed) - `/s ` or `s: ` — semantic (embedding) search - `/t ` or `t: ` — tag search @@ -88,7 +88,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle ### Key types -`ImageRecord` (mirrored in `store.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization. +`ImageRecord` (mirrored in `src/store/types.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization. ## Development notes diff --git a/src/store.ts b/src/store.ts deleted file mode 100644 index 5b7fed1..0000000 --- a/src/store.ts +++ /dev/null @@ -1,3243 +0,0 @@ -import { create } from "zustand"; -import { invoke } from "@tauri-apps/api/core"; -import { listen, UnlistenFn } from "@tauri-apps/api/event"; -import { appDataDir, join } from "@tauri-apps/api/path"; -import { getVersion } from "@tauri-apps/api/app"; -import { check, Update } from "@tauri-apps/plugin-updater"; -import { relaunch } from "@tauri-apps/plugin-process"; -import { notifyTaskComplete } from "./notifications"; -import { getChangelogForVersion } from "./changelog"; - -// Per-folder debounce timers for batching notifications. -// Keyed as `${folderId}:embedding` or `${folderId}:tagging`. -const notificationTimers = new Map>(); -const NOTIFICATION_DEBOUNCE_MS = 6000; -const THEME_KEY = "phokus-theme"; -const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay"; -const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute"; -const SLIDESHOW_INTERVAL_KEY = "phokus.slideshowIntervalSeconds"; -const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder"; -const SLIDESHOW_TRANSITION_KEY = "phokus.slideshowTransition"; - -export interface Folder { - id: number; - path: string; - name: string; - image_count: number; - indexed_at: string | null; - scan_error: string | null; - sort_order: number; -} - -export interface DirEntry { - name: string; - path: string; - has_children: boolean; -} - -export interface DirListing { - current: string | null; - parent: string | null; - entries: DirEntry[]; -} - -export type FolderAddResult = - | { status: "added"; data: Folder } - | { status: "skipped"; data: string } - | { status: "error"; data: string }; - -export type MediaKind = "image" | "video"; -export type MediaFilter = "all" | MediaKind; -export type ZoomPreset = "compact" | "comfortable" | "detail"; -export type SearchMode = "filename" | "semantic"; -export type SearchCommand = "filename" | "semantic" | "tag"; -export type CaptionAcceleration = "auto" | "cpu" | "directml"; -export type CaptionDetail = "short" | "detailed" | "paragraph"; -export type TaggerAcceleration = "auto" | "cpu" | "directml"; -export type TaggerModel = "wd" | "joytag"; -export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; -export type TaggingQueueScope = "all" | "selected"; -export type SimilarScope = "all_media" | "current_folder" | "current_album"; -export type ExploreMode = "visual" | "tags"; -export type AppTheme = "phokus" | "subtle-light" | "conventional-dark"; -export type SlideshowOrder = "sequential" | "random"; -export type SlideshowTransition = "soft-fade" | "gentle-motion"; - -export interface ImageRecord { - id: number; - folder_id: number; - path: string; - filename: string; - thumbnail_path: string | null; - width: number | null; - height: number | null; - file_size: number; - created_at: string | null; - modified_at: string | null; - taken_at: string | null; - mime_type: string; - media_kind: MediaKind; - duration_ms: number | null; - video_codec: string | null; - audio_codec: string | null; - metadata_updated_at: string | null; - metadata_error: string | null; - favorite: boolean; - rating: number; - embedding_status: string; - embedding_model: string | null; - embedding_updated_at: string | null; - embedding_error: string | null; - generated_caption: string | null; - caption_model: string | null; - caption_updated_at: string | null; - caption_error: string | null; - ai_rating: AiRating | null; - ai_tagger_model: string | null; - ai_tagged_at: string | null; - ai_tagger_error: string | null; -} - -export interface ImageTag { - id: number; - image_id: number; - tag: string; - source: "user" | "ai"; - ai_model: string | null; - confidence: number | null; - created_at: string; -} - -export interface DatabaseInfo { - size_mb: number; - reclaimable_mb: number; -} - -export interface VacuumResult { - before_mb: number; - after_mb: number; - freed_mb: number; -} - -export interface OrphanedThumbnailsInfo { - count: number; - size_mb: number; -} - -export interface CleanupOrphanedThumbnailsResult { - deleted_count: number; - freed_mb: number; -} - -export interface TaggerModelStatus { - model_id: string; - model_name: string; - local_dir: string; - ready: boolean; - missing_files: string[]; -} - -export interface TaggerModelProgress { - total_files: number; - completed_files: number; - current_file: string | null; - downloaded_bytes: number | null; - total_bytes: number | null; - done: boolean; -} - -export interface IndexProgress { - folder_id: number; - total: number; - indexed: number; - current_file: string; - done: boolean; -} - -export interface FolderJobProgress { - folder_id: number; - thumbnail_pending: number; - metadata_pending: number; - embedding_pending: number; - embedding_ready: number; - embedding_failed: number; - caption_pending: number; - caption_ready: number; - caption_failed: number; - tagging_pending: number; - tagging_ready: number; - tagging_failed: number; -} - -export interface MediaJobProgressEvent { - progress: FolderJobProgress[]; -} - -export interface IndexedImagesBatch { - folder_id: number; - images: ImageRecord[]; -} - -export interface ThumbnailBatch { - images: ImageRecord[]; -} - -export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline" | "album"; - -export interface Album { - id: number; - name: string; - cover_image_id: number | null; - cover_thumbnail_path: string | null; - image_count: number; - sort_order: number; - created_at: string; - updated_at: string; -} - -export interface ImageExif { - make: string | null; - model: string | null; - lens: string | null; - iso: string | null; - f_number: string | null; - exposure_time: string | null; - focal_length: string | null; - datetime_original: string | null; - gps_lat: number | null; - gps_lon: number | null; -} - -export interface VisualClusterEntry { - count: number; - representative_image_id: number; - thumbnail_path: string | null; - image_ids: number[]; -} - -export interface ExploreTagEntry { - tag: string; - count: number; - representative_image_id: number; - thumbnail_path: string | null; - has_ai_source: boolean; - has_user_source: boolean; -} - -export interface RelatedTagEntry { - tag: string; - shared_count: number; -} - -export interface DuplicateGroup { - file_hash: string; - file_size: number; - images: ImageRecord[]; -} - -export interface DuplicateScanProgress { - phase: "checking" | "hashing" | "confirming"; - processed: number; - total: number; - skipped: number; -} - -interface DuplicateScanResult { - groups: DuplicateGroup[]; - scanned_files: number; - candidate_files: number; - skipped_files: number; -} - -export interface SimilarImagesPage { - images: ImageRecord[]; - offset: number; - limit: number; - has_more: boolean; -} - -export interface CaptionModelStatus { - model_id: string; - model_name: string; - local_dir: string; - ready: boolean; - missing_files: string[]; -} - -export interface CaptionModelProgress { - total_files: number; - completed_files: number; - current_file: string | null; - done: boolean; -} - -export interface CaptionRuntimeSessionProbe { - file: string; - inputs: string[]; - outputs: string[]; -} - -export interface CaptionRuntimeProbe { - ready: boolean; - acceleration: CaptionAcceleration; - detail: CaptionDetail; - tokenizer_vocab_size: number; - sessions: CaptionRuntimeSessionProbe[]; -} - -export interface CaptionVisionProbe { - input_shape: number[]; - output_shape: number[]; - output_values: number; - acceleration: CaptionAcceleration; -} - -export interface TaggerRuntimeSessionProbe { - file: string; - inputs: string[]; - outputs: string[]; -} - -export interface TaggerRuntimeProbe { - ready: boolean; - acceleration: TaggerAcceleration; - session: TaggerRuntimeSessionProbe; -} - -export interface ParsedSearch { - mode: SearchCommand; - query: string; - prefix: string | null; -} - -export type SortOrder = - | "date_desc" - | "date_asc" - | "name_asc" - | "name_desc" - | "size_desc" - | "size_asc" - | "rating_desc" - | "rating_asc" - | "duration_desc" - | "duration_asc" - | "taken_desc" - | "taken_asc"; - -export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error"; - -export type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging"; - -const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"]; - -interface FolderWorkerStates { - folder_id: number; - thumbnail_paused: boolean; - metadata_paused: boolean; - embedding_paused: boolean; - tagging_paused: boolean; -} - -export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error"; - -interface FfmpegProgressEvent { - phase: string; - downloaded_bytes: number | null; - total_bytes: number | null; - error: string | null; -} - -// The Update handle from the plugin carries the download method; it's not -// serializable state, so it lives outside the store. -let pendingUpdate: Update | null = null; - -interface GalleryState { - folders: Folder[]; - selectedFolderId: number | null; - images: ImageRecord[]; - totalImages: number; - loadedCount: number; - loadingImages: boolean; - imageLoadError: string | null; - search: string; - searchMode: SearchMode; - sort: SortOrder; - mediaFilter: MediaFilter; - favoritesOnly: boolean; - minimumRating: number; - failedEmbeddingsOnly: boolean; - failedTaggingOnly: boolean; - colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter - colorBackfill: { processed: number; total: number; done: boolean } | null; - zoomPreset: ZoomPreset; - selectedImage: ImageRecord | null; - collectionTitle: string | null; - similarSourceImageId: number | null; - similarSourceFolderId: number | null; - similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album") - similarHasMore: boolean; - similarScope: SimilarScope; - similarFolderId: number | null; - similarCrop: { x: number; y: number; w: number; h: number } | null; - galleryScrollResetKey: number; - activeView: ActiveView; - exploreMode: ExploreMode; - tagManagerOpen: boolean; - visualClusterEntries: VisualClusterEntry[]; - visualClusterLoading: boolean; - visualClusterFolderId: number | null | undefined; // undefined = never loaded - exploreTagEntries: ExploreTagEntry[]; - exploreTagLoading: boolean; - // Cache-freshness key: the folder the loaded tags belong to. Set to undefined - // by content mutations (tag add/remove/rename/delete) to mark the cache dirty - // and force the next load to refetch. - exploreTagsFolderId: number | null | undefined; - // The folder whose tags are actually on screen. Kept separate from the dirty - // marker above so a same-folder invalidation isn't mistaken for a folder switch - // (which would wipe the visible list and remount manager UI mid-refresh). - exploreTagsShownFolderId: number | null | undefined; - relatedTagsByKey: Record; - indexingProgress: Record; - mediaJobProgress: Record; - cacheDir: string; - captionModelStatus: CaptionModelStatus | null; - captionModelPreparing: boolean; - captionModelError: string | null; - captionModelProgress: CaptionModelProgress | null; - captionRuntimeProbe: CaptionRuntimeProbe | null; - captionRuntimeChecking: boolean; - captionAcceleration: CaptionAcceleration; - captionDetail: CaptionDetail; - aiCaptionsEnabled: boolean; - settingsOpen: boolean; - folderPickerOpen: boolean; - taggingQueueScope: TaggingQueueScope; - taggingQueueFolderIds: number[]; - mutedFolderIds: number[]; - notificationsPaused: boolean; - workerPausesPersist: boolean; - theme: AppTheme; - lightboxAutoplay: boolean; - lightboxAutoMute: boolean; - slideshowIntervalSeconds: number; - slideshowOrder: SlideshowOrder; - slideshowTransition: SlideshowTransition; - // Per-folder background-worker pause flags, shared by the BackgroundTasks - // bar and the sidebar folder context menu. - workerPaused: Record>; - - appVersion: string | null; - buildVariant: "cpu" | "cuda" | null; - updateStatus: UpdateStatus; - updateVersion: string | null; - updateProgress: number | null; // 0..1 download progress, null while size unknown - updateError: string | null; - updateDismissed: boolean; - // "What's New" greeting after a version change. `whatsNewToast` holds the - // version to advertise in the corner toast (null = hidden); `whatsNewOpen` - // controls the full changelog modal. - whatsNewOpen: boolean; - whatsNewToast: string | null; - - ffmpegStatus: FfmpegStatus; - ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null; - ffmpegError: string | null; - onboardingCompleted: boolean | null; // null = not loaded yet - onboardingOpen: boolean; - onboardingStep: number; - - taggerModelStatus: TaggerModelStatus | null; - taggerModelPreparing: boolean; - taggerModelError: string | null; - taggerModelProgress: TaggerModelProgress | null; - taggerModel: TaggerModel; - taggerAcceleration: TaggerAcceleration; - taggerThreshold: number; - taggerBatchSize: number; - taggerRuntimeProbe: TaggerRuntimeProbe | null; - taggerRuntimeChecking: boolean; - - duplicateGroups: DuplicateGroup[]; - duplicateScanning: boolean; - duplicateScanProgress: DuplicateScanProgress | null; - duplicateScanError: string | null; - duplicateScanWarning: string | null; - duplicateSelectedIds: Set; - duplicateLastScanned: number | null; // Unix timestamp (seconds) - duplicateScanFolderId: number | null | undefined; // undefined = never scanned - - // Gallery multi-select (Feature A) - gallerySelectedIds: Set; - - // Albums (Feature B) - albums: Album[]; - albumsLoaded: boolean; - selectedAlbumId: number | null; - - loadFolders: () => Promise; - loadBackgroundJobProgress: () => Promise; - addFolder: (path: string) => Promise; - addFolders: (paths: string[]) => Promise; - listDirectories: (path: string | null) => Promise; - removeFolder: (folderId: number) => Promise; - reindexFolder: (folderId: number) => Promise; - renameFolder: (folderId: number, newName: string) => Promise; - updateFolderPath: (folderId: number, newPath: string) => Promise; - reorderFolders: (folderIds: number[]) => Promise; - selectFolder: (folderId: number | null) => void; - setViewFolderScope: (folderId: number | null) => void; - loadImages: (reset?: boolean) => Promise; - loadMoreImages: () => Promise; - setSearch: (search: string) => void; - clearSearch: () => void; - resetSearch: () => void; - setSearchMode: (mode: SearchMode) => void; - setSort: (sort: SortOrder) => void; - setMediaFilter: (filter: MediaFilter) => void; - setFavoritesOnly: (favoritesOnly: boolean) => void; - setMinimumRating: (minimumRating: number) => void; - setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; - setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; - setColorFilter: (color: [number, number, number] | null) => void; - showFailedTagging: (folderId: number) => void; - setZoomPreset: (zoomPreset: ZoomPreset) => void; - openImage: (image: ImageRecord) => void; - closeImage: () => void; - setView: (view: ActiveView) => void; - setExploreMode: (mode: ExploreMode) => void; - setTagManagerOpen: (open: boolean) => void; - openTagManager: () => void; - loadVisualClusters: (options?: { force?: boolean }) => Promise; - loadExploreTags: (options?: { force?: boolean }) => Promise; - loadRelatedTags: (tag: string) => Promise; - showVisualCluster: (imageIds: number[]) => Promise; - searchForTag: (tag: string) => void; - loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; - loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise; - // Entry points that decide scope (album when launched from an album, else folder/all per similarScope). - findSimilar: (imageId: number, sourceFolderId: number | null) => Promise; - findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise; - setSimilarScope: (scope: SimilarScope) => void; - suggestImageTags: (imageId: number) => Promise; - loadCaptionModelStatus: () => Promise; - prepareCaptionModel: () => Promise; - deleteCaptionModel: () => Promise; - probeCaptionRuntime: () => Promise; - probeCaptionImage: (imageId: number) => Promise; - generateCaptionForImage: (imageId: number) => Promise; - queueCaptionJobs: (folderId?: number | null) => Promise; - queueCaptionForImage: (imageId: number) => Promise; - clearCaptionJobs: (folderId?: number | null) => Promise; - resetGeneratedCaptions: (folderId?: number | null) => Promise; - loadCaptionAcceleration: () => Promise; - setCaptionAcceleration: (acceleration: CaptionAcceleration) => Promise; - loadCaptionDetail: () => Promise; - setCaptionDetail: (detail: CaptionDetail) => Promise; - setAiCaptionsEnabled: (enabled: boolean) => void; - setSettingsOpen: (open: boolean) => void; - setFolderPickerOpen: (open: boolean) => void; - loadTaggingQueueScope: () => Promise; - setTaggingQueueScope: (scope: TaggingQueueScope) => void; - loadTaggingQueueFolderIds: () => Promise; - toggleTaggingQueueFolder: (folderId: number) => void; - setTaggingQueueFolderIds: (folderIds: number[]) => void; - loadMutedFolderIds: () => Promise; - toggleMutedFolder: (folderId: number) => void; - loadNotificationsPaused: () => Promise; - setNotificationsPaused: (paused: boolean) => void; - loadWorkerPausesPersist: () => Promise; - setWorkerPausesPersist: (persist: boolean) => void; - setTheme: (theme: AppTheme) => void; - setLightboxAutoplay: (enabled: boolean) => void; - setLightboxAutoMute: (enabled: boolean) => void; - setSlideshowIntervalSeconds: (seconds: number) => void; - setSlideshowOrder: (order: SlideshowOrder) => void; - setSlideshowTransition: (transition: SlideshowTransition) => void; - loadWorkerStates: () => Promise; - setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; - setAllWorkersPaused: (folderId: number, paused: boolean) => void; - loadAppVersion: () => Promise; - checkForUpdates: (options?: { quiet?: boolean }) => Promise; - installUpdate: () => Promise; - dismissUpdate: () => void; - initWhatsNew: () => Promise; - openWhatsNew: () => void; - closeWhatsNew: () => void; - dismissWhatsNewToast: () => void; - loadFfmpegStatus: () => Promise; - retryFfmpegDownload: () => Promise; - loadOnboardingCompleted: () => Promise; - completeOnboarding: () => void; - openOnboarding: () => void; - setOnboardingStep: (step: number) => void; - openAppDataFolder: () => Promise; - getDatabaseInfo: () => Promise; - vacuumDatabase: () => Promise; - rebuildSemanticIndex: () => Promise; - getOrphanedThumbnailsInfo: () => Promise; - cleanupOrphanedThumbnails: () => Promise; - retryFailedEmbeddings: (folderId: number) => Promise; - updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; - setCacheDir: (dir: string) => void; - subscribeToProgress: () => Promise; - - loadTaggerModelStatus: () => Promise; - prepareTaggerModel: () => Promise; - deleteTaggerModel: () => Promise; - loadTaggerAcceleration: () => Promise; - setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise; - loadTaggerModel: () => Promise; - setTaggerModel: (model: TaggerModel) => Promise; - loadTaggerThreshold: () => Promise; - setTaggerThreshold: (threshold: number, model?: TaggerModel) => Promise; - loadTaggerBatchSize: () => Promise; - setTaggerBatchSize: (batchSize: number) => Promise; - probeTaggerRuntime: () => Promise; - queueTaggingJobs: (folderId?: number | null) => Promise; - queueTaggingJobsForFolders: (folderIds: number[]) => Promise; - queueTaggingForImage: (imageId: number) => Promise; - clearTaggingJobs: (folderId?: number | null) => Promise; - clearTaggingJobsForFolders: (folderIds: number[]) => Promise; - resetAiTags: (folderId?: number | null) => Promise; - resetAiTagsForFolders: (folderIds: number[]) => Promise; - loadDuplicateScanCache: (folderId?: number | null) => Promise; - scanDuplicates: (folderId?: number | null) => Promise; - toggleDuplicateSelected: (imageId: number) => void; - selectAllDuplicates: (imageIds: number[]) => void; - selectKeepFirstAllGroups: () => void; - clearDuplicateSelection: () => void; - deleteSelectedDuplicates: () => Promise; - getImageTags: (imageId: number) => Promise; - addUserTag: (imageId: number, tag: string) => Promise; - removeTag: (tagId: number) => Promise; - getImageExif: (imageId: number) => Promise; - renameTag: (from: string, to: string) => Promise; - deleteTag: (tag: string) => Promise; - - // Gallery multi-select (Feature A) - toggleGallerySelected: (imageId: number) => void; - selectAllGallery: () => void; - clearGallerySelection: () => void; - bulkSetFavorite: (favorite: boolean) => Promise; - bulkSetRating: (rating: number) => Promise; - bulkAddTags: (tags: string[]) => Promise; - bulkRemoveTag: (tag: string) => Promise; - bulkDeleteSelected: () => Promise; - - // Albums (Feature B) - loadAlbums: () => Promise; - createAlbum: (name: string) => Promise; - renameAlbum: (albumId: number, name: string) => Promise; - deleteAlbum: (albumId: number) => Promise; - deleteAlbums: (albumIds: number[]) => Promise; - reorderAlbums: (albumIds: number[]) => Promise; - addToAlbum: (albumId: number, imageIds: number[]) => Promise; - removeFromAlbum: (albumId: number, imageIds: number[]) => Promise; - viewAlbum: (albumId: number) => void; -} - -const PAGE_SIZE = 200; -// Timeline loads its full filtered set in one indexed taken_at query so the -// scrubber can span the entire library and jump to any month. Rendering is -// virtualized, so the cost is one query + records in memory — fine at this scale. -const TIMELINE_PAGE_SIZE = 100000; -const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled"; -const SIMILAR_DISTANCE_THRESHOLD = 0.24; - -// Single token shared by all gallery-producing requests (folder loads, searches, -// similarity, region search). Any new request increments it so a stale response -// from a previous collection type cannot overwrite newer results. -let galleryRequestToken = 0; -let visualClusterRequestToken = 0; -let exploreTagRequestToken = 0; -let exploreTagRefreshTimer: ReturnType | null = null; -const EXPLORE_TAG_REFRESH_IDLE_MS = 900; -const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500; - -function scopeHasTaggingPending( - progressByFolder: Record, - folderId: number | null, -): boolean { - if (folderId === null) { - return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0); - } - return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0; -} - -function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean { - return scopeFolderId === null || scopeFolderId === progressFolderId; -} - -function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean { - return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId); -} - -function scheduleExploreTagRefresh(load: () => void, delayMs: number) { - if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer); - exploreTagRefreshTimer = setTimeout(() => { - exploreTagRefreshTimer = null; - load(); - }, delayMs); -} - -function initialAiCaptionsEnabled(): boolean { - if (typeof window === "undefined") return false; - return window.localStorage.getItem(AI_CAPTIONS_ENABLED_KEY) === "true"; -} - -function initialBoolSetting(key: string, fallback: boolean): boolean { - if (typeof window === "undefined") return fallback; - const stored = window.localStorage.getItem(key); - return stored === null ? fallback : stored === "true"; -} - -function initialNumberSetting(key: string, fallback: number, min: number, max: number): number { - if (typeof window === "undefined") return fallback; - const raw = window.localStorage.getItem(key); - if (raw === null) return fallback; - const stored = Number(raw); - if (!Number.isFinite(stored)) return fallback; - return Math.min(max, Math.max(min, stored)); -} - -function initialSlideshowOrder(): SlideshowOrder { - if (typeof window === "undefined") return "sequential"; - const stored = window.localStorage.getItem(SLIDESHOW_ORDER_KEY); - return stored === "random" ? "random" : "sequential"; -} - -function initialSlideshowTransition(): SlideshowTransition { - if (typeof window === "undefined") return "soft-fade"; - const stored = window.localStorage.getItem(SLIDESHOW_TRANSITION_KEY); - return stored === "gentle-motion" ? "gentle-motion" : "soft-fade"; -} - -function initialTheme(): AppTheme { - if (typeof window === "undefined") return "phokus"; - const saved = window.localStorage.getItem(THEME_KEY); - const theme: AppTheme = - saved === "subtle-light" || saved === "conventional-dark" ? saved : "phokus"; - document.documentElement.dataset.theme = theme; - return theme; -} - -function mergeIntoVisibleWindow( - currentImages: ImageRecord[], - newImages: ImageRecord[], - sort: SortOrder, - windowSize: number, -): ImageRecord[] { - const merged = mergeImages(currentImages, newImages, sort); - return merged.slice(0, Math.max(windowSize, 0)); -} - -function matchesSearch(image: ImageRecord, search: string): boolean { - if (!search) return true; - return image.filename.toLowerCase().includes(search.toLowerCase()); -} - -function isDerivedCollectionTitle(collectionTitle: string | null): boolean { - return collectionTitle !== null; -} - -export function parseSearchValue(search: string): ParsedSearch { - if (!search.trim()) { - return { mode: "filename", query: "", prefix: null }; - } - - const slashPrefix = search.match(/^\/([a-z])(?:\s|$)/i); - if (slashPrefix) { - const rawPrefix = slashPrefix[1].toLowerCase(); - const query = search.length > 3 ? search.slice(3) : ""; - if (rawPrefix === "s") { - return { mode: "semantic", query, prefix: "/s" }; - } - if (rawPrefix === "t") { - return { mode: "tag", query, prefix: "/t" }; - } - return { mode: "filename", query, prefix: rawPrefix === "f" ? "/f" : null }; - } - - const trimmed = search.trim(); - const match = trimmed.match(/^([a-z]):\s*(.*)$/i); - if (!match) { - return { mode: "filename", query: trimmed, prefix: null }; - } - - const rawPrefix = match[1].toLowerCase(); - const query = match[2].trim(); - if (rawPrefix === "s") { - return { mode: "semantic", query, prefix: "s:" }; - } - if (rawPrefix === "t") { - return { mode: "tag", query, prefix: "t:" }; - } - return { mode: "filename", query, prefix: rawPrefix === "f" ? "f:" : null }; -} - -export function searchModeLabel(mode: SearchCommand): string { - switch (mode) { - case "semantic": - return "Semantic Search"; - case "tag": - return "Tag Search"; - default: - return "Filename Search"; - } -} - -function matchesFilters( - image: ImageRecord, - selectedFolderId: number | null, - mediaFilter: MediaFilter, - favoritesOnly: boolean, - minimumRating: number, - failedEmbeddingsOnly: boolean, - failedTaggingOnly: boolean, - search: string, -): boolean { - const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; - const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter; - const matchesFavorite = !favoritesOnly || image.favorite; - const matchesRating = image.rating >= minimumRating; - const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; - const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null; - return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search); -} - -function compareNullableNumber(a: number | null, b: number | null): number { - return (a ?? 0) - (b ?? 0); -} - -function compareNullableDate(a: string | null, b: string | null): number { - return (a ? Date.parse(a) : 0) - (b ? Date.parse(b) : 0); -} - -function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number { - switch (sort) { - case "name_asc": - return a.filename.localeCompare(b.filename); - case "name_desc": - return b.filename.localeCompare(a.filename); - case "date_asc": - return compareNullableDate(a.modified_at, b.modified_at); - case "date_desc": - return compareNullableDate(b.modified_at, a.modified_at); - case "size_asc": - return compareNullableNumber(a.file_size, b.file_size); - case "size_desc": - return compareNullableNumber(b.file_size, a.file_size); - case "rating_asc": - return compareNullableNumber(a.rating, b.rating); - case "rating_desc": - return compareNullableNumber(b.rating, a.rating); - case "duration_asc": - return compareNullableNumber(a.duration_ms, b.duration_ms); - case "duration_desc": - return compareNullableNumber(b.duration_ms, a.duration_ms); - case "taken_asc": - return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at); - case "taken_desc": - return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at); - default: - return compareNullableDate(b.modified_at, a.modified_at); - } -} - -function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sort: SortOrder): ImageRecord[] { - const merged = new Map(); - - for (const image of currentImages) { - merged.set(image.path, image); - } - - for (const image of newImages) { - merged.set(image.path, image); - } - - return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort)); -} - -function countNewImages(currentImages: ImageRecord[], newImages: ImageRecord[]): number { - const existingPaths = new Set(currentImages.map((image) => image.path)); - let count = 0; - - for (const image of newImages) { - if (!existingPaths.has(image.path)) { - existingPaths.add(image.path); - count += 1; - } - } - - return count; -} - -function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] { - return mergeImages(images, [updatedImage], sort); -} - -function replaceExistingImages( - currentImages: ImageRecord[], - updatedImages: ImageRecord[], -): ImageRecord[] { - // Replace matched records in place WITHOUT re-sorting. `media-updated` carries - // thumbnail/metadata fills that don't move an item in the list (Timeline - // re-buckets by taken_at separately), and it fires constantly while the - // background workers run. Re-sorting here meant an O(n log n) pass on every - // batch — fine for the ~200-item gallery window, but a UI-freezing churn in - // Timeline view where `images` can hold the entire library (TIMELINE_PAGE_SIZE). - // Returning the same array reference when nothing matched also avoids a wasted - // re-render. Relative order for just-updated items is corrected on next load. - const updatesByPath = new Map(updatedImages.map((image) => [image.path, image])); - let changed = false; - const nextImages = currentImages.map((image) => { - const update = updatesByPath.get(image.path); - if (!update) return image; - changed = true; - return update; - }); - return changed ? nextImages : currentImages; -} - -export function tileSizeForZoom(zoomPreset: ZoomPreset): number { - switch (zoomPreset) { - case "compact": - return 160; - case "detail": - return 280; - default: - return 220; - } -} - -export const useGalleryStore = create((set, get) => ({ - folders: [], - selectedFolderId: null, - images: [], - totalImages: 0, - loadedCount: 0, - loadingImages: false, - imageLoadError: null, - search: "", - searchMode: "filename", - sort: "date_desc", - mediaFilter: "all", - favoritesOnly: false, - minimumRating: 0, - failedEmbeddingsOnly: false, - failedTaggingOnly: false, - colorFilter: null, - colorBackfill: null, - zoomPreset: "comfortable", - selectedImage: null, - collectionTitle: null, - similarSourceImageId: null, - similarSourceFolderId: null, - similarSourceAlbumId: null, - similarHasMore: false, - similarScope: "all_media", - similarFolderId: null, - similarCrop: null, - galleryScrollResetKey: 0, - activeView: "gallery", - exploreMode: "visual", - tagManagerOpen: false, - visualClusterEntries: [], - visualClusterLoading: false, - visualClusterFolderId: undefined, - exploreTagEntries: [], - exploreTagLoading: false, - exploreTagsFolderId: undefined, - exploreTagsShownFolderId: undefined, - relatedTagsByKey: {}, - indexingProgress: {}, - mediaJobProgress: {}, - cacheDir: "", - captionModelStatus: null, - captionModelPreparing: false, - captionModelError: null, - captionModelProgress: null, - captionRuntimeProbe: null, - captionRuntimeChecking: false, - captionAcceleration: "auto", - captionDetail: "paragraph", - aiCaptionsEnabled: initialAiCaptionsEnabled(), - settingsOpen: false, - folderPickerOpen: false, - taggingQueueScope: "all", - taggingQueueFolderIds: [], - mutedFolderIds: [], - notificationsPaused: false, - workerPausesPersist: false, - theme: initialTheme(), - lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), - lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), - slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60), - slideshowOrder: initialSlideshowOrder(), - slideshowTransition: initialSlideshowTransition(), - workerPaused: {}, - - appVersion: null, - buildVariant: null, - updateStatus: "idle", - updateVersion: null, - updateProgress: null, - updateError: null, - updateDismissed: false, - whatsNewOpen: false, - whatsNewToast: null, - - ffmpegStatus: "unknown", - ffmpegProgress: null, - ffmpegError: null, - onboardingCompleted: null, - onboardingOpen: false, - onboardingStep: 0, - - taggerModelStatus: null, - taggerModelPreparing: false, - taggerModelError: null, - taggerModelProgress: null, - taggerModel: "wd", - taggerAcceleration: "auto", - taggerThreshold: 0.35, - taggerBatchSize: 8, - taggerRuntimeProbe: null, - taggerRuntimeChecking: false, - - duplicateGroups: [], - duplicateScanning: false, - duplicateScanProgress: null, - duplicateScanError: null, - duplicateScanWarning: null, - duplicateSelectedIds: new Set(), - duplicateLastScanned: null, - duplicateScanFolderId: undefined, - - gallerySelectedIds: new Set(), - - albums: [], - albumsLoaded: false, - selectedAlbumId: null, - - setCacheDir: (cacheDir) => set({ cacheDir }), - - loadFolders: async () => { - const folders = await invoke("get_folders"); - set((state) => { - const folderIds = new Set(folders.map((folder) => folder.id)); - const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId)); - return { - folders, - taggingQueueFolderIds: nextSelected, - }; - }); - }, - - loadBackgroundJobProgress: async () => { - const progress = await invoke("get_background_job_progress"); - set(() => ({ - mediaJobProgress: Object.fromEntries(progress.map((entry) => [entry.folder_id, entry])), - })); - }, - - addFolder: async (path) => { - const { loadFolders, loadBackgroundJobProgress } = get(); - await invoke("add_folder", { path }); - await loadFolders(); - await loadBackgroundJobProgress(); - }, - - addFolders: async (paths) => { - const { loadFolders, loadBackgroundJobProgress } = get(); - const results = await invoke("add_folders", { paths }); - await loadFolders(); - await loadBackgroundJobProgress(); - return results; - }, - - listDirectories: (path) => invoke("list_directories", { path }), - - removeFolder: async (folderId) => { - const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get(); - // Optimistically drop it from the sidebar for instant feedback (the backend - // delete of its images/thumbnails can take a moment), clearing the active - // selection if it was this folder. - set((state) => { - const folders = state.folders.filter((folder) => folder.id !== folderId); - return selectedFolderId === folderId ? { folders, selectedFolderId: null } : { folders }; - }); - try { - await invoke("remove_folder", { folderId }); - } catch (error) { - // Removal failed — resync the authoritative list and surface the error. - await loadFolders(); - throw error; - } - await loadFolders(); - await loadBackgroundJobProgress(); - // Invalidate tag cloud and explore-tags cache since library content changed. - set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined }); - // Always refresh the gallery: the removed folder's images may be on screen - // (e.g. in All Media), not only when that folder was the active selection. - await loadImages(true); - }, - - reindexFolder: async (folderId) => { - const { loadFolders, loadBackgroundJobProgress } = get(); - await invoke("reindex_folder", { folderId }); - await loadFolders(); - // Invalidate tag cloud cache since embeddings will be regenerated - set({ visualClusterFolderId: undefined, visualClusterEntries: [] }); - await loadBackgroundJobProgress(); - }, - - renameFolder: async (folderId, newName) => { - await invoke("rename_folder", { folderId, newName }); - await get().loadFolders(); - }, - - updateFolderPath: async (folderId, newPath) => { - const { loadFolders, loadBackgroundJobProgress } = get(); - await invoke("update_folder_path", { folderId, newPath }); - await loadFolders(); - await loadBackgroundJobProgress(); - }, - - reorderFolders: async (folderIds) => { - const previous = get().folders; - const byId = new Map(previous.map((folder) => [folder.id, folder])); - const folders = folderIds - .map((id, index) => { - const folder = byId.get(id); - return folder ? { ...folder, sort_order: index + 1 } : null; - }) - .filter((folder): folder is Folder => folder !== null); - set({ folders }); - try { - await invoke("reorder_folders", { params: { folder_ids: folderIds } }); - } catch (error) { - set({ folders: previous }); - throw error; - } - }, - - selectFolder: (folderId) => { - // Leaving any album: drop the album-origin scope so the Folder/All pills - // highlight correctly again. - const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope; - set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); - 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 ExploreView'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, failedTaggingOnly, colorFilter, activeView } = get(); - const parsedSearch = parseSearchValue(search); - const requestToken = ++galleryRequestToken; - // Any fresh collection load invalidates a selection that referenced the - // previous set of visible images. - set({ loadingImages: true, imageLoadError: null, ...(reset ? { gallerySelectedIds: new Set() } : {}) }); - - try { - // Album view loads from the album membership, honoring sort changes from - // the Toolbar while staying within the album (ignores folder/search/filters). - if (activeView === "album") { - const albumId = get().selectedAlbumId; - if (albumId === null) { - set({ loadingImages: false }); - return; - } - const offset = reset ? 0 : loadedCount; - const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { - params: { album_id: albumId, sort, offset, limit: PAGE_SIZE }, - }); - if (requestToken !== galleryRequestToken) return; - const albumName = get().albums.find((entry) => entry.id === albumId)?.name ?? "Album"; - set((state) => ({ - images: reset ? result.images : [...state.images, ...result.images], - totalImages: result.total, - loadedCount: reset ? result.images.length : state.loadedCount + result.images.length, - loadingImages: false, - collectionTitle: albumName, - })); - return; - } - - if (parsedSearch.mode === "semantic" && parsedSearch.query) { - const images = await invoke("semantic_search_images", { - params: { - query: parsedSearch.query, - folder_id: selectedFolderId, - media_kind: mediaFilter === "all" ? null : mediaFilter, - favorites_only: favoritesOnly, - rating_min: minimumRating > 0 ? minimumRating : null, - limit: PAGE_SIZE, - }, - }); - - if (requestToken !== galleryRequestToken) return; - set({ - images, - totalImages: images.length, - loadedCount: images.length, - loadingImages: false, - collectionTitle: `Semantic search: ${parsedSearch.query}`, - selectedFolderId, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - }); - return; - } - - if (parsedSearch.mode === "tag" && parsedSearch.query) { - const offset = reset ? 0 : loadedCount; - const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("search_images_by_tag", { - params: { - query: parsedSearch.query, - folder_id: selectedFolderId, - media_kind: mediaFilter === "all" ? null : mediaFilter, - favorites_only: favoritesOnly, - rating_min: minimumRating > 0 ? minimumRating : null, - color: colorFilter, - limit: PAGE_SIZE, - offset, - }, - }); - - if (requestToken !== galleryRequestToken) return; - if (reset) { - set({ - images: result.images, - totalImages: result.total, - loadedCount: result.images.length, - loadingImages: false, - collectionTitle: `Tag search: ${parsedSearch.query}`, - selectedFolderId, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - }); - } else { - set((state) => ({ - images: [...state.images, ...result.images], - loadedCount: state.loadedCount + result.images.length, - totalImages: result.total, - loadingImages: false, - })); - } - return; - } - - const offset = reset ? 0 : loadedCount; - const result = await invoke<{ - images: ImageRecord[]; - total: number; - offset: number; - limit: number; - }>("get_images", { - params: { - folder_id: selectedFolderId, - search: parsedSearch.query || null, - media_kind: mediaFilter === "all" ? null : mediaFilter, - favorites_only: favoritesOnly, - rating_min: minimumRating > 0 ? minimumRating : null, - embedding_failed_only: failedEmbeddingsOnly, - tagging_failed_only: failedTaggingOnly, - color: colorFilter, - sort, - offset, - limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, - }, - }); - - if (requestToken !== galleryRequestToken) return; - set((state) => ({ - images: reset ? result.images : [...state.images, ...result.images], - totalImages: result.total, - loadedCount: reset ? result.images.length : state.loadedCount + result.images.length, - loadingImages: false, - collectionTitle: reset ? null : state.collectionTitle, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - })); - } catch (error) { - if (requestToken !== galleryRequestToken) return; - console.error("Failed to load media:", error); - set({ loadingImages: false, imageLoadError: String(error) }); - } - }, - - loadMoreImages: async () => { - const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId, similarCrop } = get(); - if (loadingImages || loadedCount >= totalImages) return; - if (collectionTitle === "Explore Cluster") return; - const { activeView, selectedAlbumId, sort } = get(); - if (activeView === "album" && selectedAlbumId !== null) { - const requestToken = ++galleryRequestToken; - set({ loadingImages: true }); - try { - const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { - params: { album_id: selectedAlbumId, sort, offset: loadedCount, limit: PAGE_SIZE }, - }); - if (requestToken !== galleryRequestToken) return; - set((state) => ({ - images: [...state.images, ...result.images], - loadedCount: state.loadedCount + result.images.length, - totalImages: result.total, - loadingImages: false, - })); - } catch { - if (requestToken !== galleryRequestToken) return; - set({ loadingImages: false }); - } - return; - } - const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null; - if (collectionTitle === "Similar Images" && similarSourceImageId !== null) { - if (!similarHasMore) return; - await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId); - return; - } - if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) { - if (!similarHasMore) return; - const requestToken = ++galleryRequestToken; - set({ loadingImages: true }); - try { - const result = await invoke("find_similar_by_region", { - params: { - image_id: similarSourceImageId, - crop_x: similarCrop.x, - crop_y: similarCrop.y, - crop_w: similarCrop.w, - crop_h: similarCrop.h, - folder_id: pageAlbumId !== null ? null : similarFolderId, - album_id: pageAlbumId, - offset: loadedCount, - limit: PAGE_SIZE, - }, - }); - if (requestToken !== galleryRequestToken) return; - set((state) => ({ - images: [...state.images, ...result.images], - loadedCount: state.loadedCount + result.images.length, - totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length, - similarHasMore: result.has_more, - loadingImages: false, - })); - } catch { - if (requestToken !== galleryRequestToken) return; - set({ loadingImages: false }); - } - return; - } - await get().loadImages(false); - }, - - setSearch: (search) => { - set({ search, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - clearSearch: () => { - set({ search: "", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - resetSearch: () => { - set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setSearchMode: (searchMode) => { - set({ searchMode, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setSort: (sort) => { - set({ sort, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setMediaFilter: (mediaFilter) => { - set({ mediaFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setFavoritesOnly: (favoritesOnly) => { - set({ favoritesOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setMinimumRating: (minimumRating) => { - set({ minimumRating, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { - set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setFailedTaggingOnly: (failedTaggingOnly) => { - set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - setColorFilter: (colorFilter) => { - set({ colorFilter, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); - void get().loadImages(true); - }, - - showFailedTagging: (folderId) => { - set({ - selectedFolderId: folderId, - activeView: "gallery", - search: "", - mediaFilter: "all", - favoritesOnly: false, - minimumRating: 0, - failedEmbeddingsOnly: false, - failedTaggingOnly: true, - images: [], - loadedCount: 0, - collectionTitle: null, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - similarCrop: null, - imageLoadError: null, - }); - void get().loadImages(true); - }, - - setZoomPreset: (zoomPreset) => set({ zoomPreset }), - - openImage: (image) => set({ selectedImage: image }), - closeImage: () => set({ selectedImage: null }), - - setView: (activeView) => { - // Leaving an album view drops the album-origin similar scope. - const similarScopeReset = get().similarScope === "current_album" ? "all_media" : get().similarScope; - if (activeView === "timeline") { - set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarSourceAlbumId: null, similarScope: similarScopeReset, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null }); - void get().loadImages(true); - return; - } - if (activeView === "duplicates") { - const { selectedFolderId, duplicateScanFolderId } = get(); - if (duplicateScanFolderId !== selectedFolderId) { - set({ - activeView, - duplicateGroups: [], - duplicateLastScanned: null, - duplicateScanFolderId: undefined, - duplicateScanWarning: null, - }); - void get().loadDuplicateScanCache(selectedFolderId); - return; - } - } - // Entering Explore normally always starts in browse mode; openTagManager() is - // the only path that re-opens manage mode (it runs this then sets the flag). - set({ - activeView, - similarSourceAlbumId: null, - similarScope: similarScopeReset, - ...(activeView === "explore" ? { tagManagerOpen: false } : {}), - }); - }, - - setExploreMode: (exploreMode) => - // Manage mode only exists for the tag view; drop it when switching to visual - // clusters so re-entering the tag view starts in the normal browse state. - set(exploreMode === "visual" ? { exploreMode, tagManagerOpen: false } : { exploreMode }), - setTagManagerOpen: (tagManagerOpen) => set({ tagManagerOpen }), - // Jump straight to the tag manager from anywhere (e.g. the Settings panel): - // switch to Explore, select the tag view, open manage mode, and close Settings. - openTagManager: () => { - get().setView("explore"); - set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false }); - }, - - loadVisualClusters: async (options) => { - const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get(); - const force = options?.force ?? false; - // Skip if already loaded for this folder and not currently loading - if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) { - return; - } - const requestToken = ++visualClusterRequestToken; - // On a real folder switch, drop the previous folder's clusters so the loading - // panel shows instead of lingering stale results. A same-folder refresh keeps - // them to avoid a flicker when the cache returns instantly. - const isFolderSwitch = visualClusterFolderId !== selectedFolderId; - set({ - visualClusterLoading: true, - visualClusterFolderId: selectedFolderId, - ...(isFolderSwitch ? { visualClusterEntries: [] } : {}), - }); - try { - const entries = await invoke("get_visual_clusters", { - folderId: selectedFolderId, - }); - if (requestToken !== visualClusterRequestToken) return; - set({ visualClusterEntries: entries, visualClusterLoading: false }); - } catch (error) { - if (requestToken !== visualClusterRequestToken) return; - console.error("Failed to load tag cloud:", error); - set({ visualClusterLoading: false }); - } - }, - - loadExploreTags: async (options) => { - const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); - const force = options?.force ?? false; - if (!force && exploreTagLoading) { - return; - } - if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { - return; - } - const requestToken = ++exploreTagRequestToken; - // A real folder switch is decided by what's currently *shown*, not by the - // dirty marker — a same-folder invalidation nulls exploreTagsFolderId but - // leaves exploreTagsShownFolderId pointing at the displayed folder, so the - // visible list (and manager UI state) survives the refresh. On an actual - // switch, drop the previous folder's tags so the loading panel shows. - const { exploreTagsShownFolderId } = get(); - const isFolderSwitch = - exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId; - set({ - exploreTagLoading: true, - exploreTagsFolderId: selectedFolderId, - exploreTagsShownFolderId: selectedFolderId, - ...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}), - }); - try { - const entries = await invoke("get_explore_tags", { - params: { folder_id: selectedFolderId, limit: 180 }, - }); - if (requestToken !== exploreTagRequestToken) return; - set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} }); - } catch (error) { - if (requestToken !== exploreTagRequestToken) return; - console.error("Failed to load explore tags:", error); - set({ exploreTagLoading: false }); - } - }, - - showVisualCluster: async (imageIds) => { - const requestToken = ++galleryRequestToken; - set((state) => ({ - activeView: "gallery", - search: "", - images: [], - totalImages: imageIds.length, - loadedCount: 0, - loadingImages: true, - collectionTitle: "Explore Cluster", - imageLoadError: null, - similarSourceImageId: null, - similarSourceFolderId: null, - similarHasMore: false, - similarFolderId: null, - gallerySelectedIds: new Set(), - selectedAlbumId: null, - galleryScrollResetKey: state.galleryScrollResetKey + 1, - })); - - try { - const images = await invoke("get_images_by_ids", { - params: { image_ids: imageIds }, - }); - if (requestToken !== galleryRequestToken) return; - set({ - images, - totalImages: images.length, - loadedCount: images.length, - loadingImages: false, - imageLoadError: null, - collectionTitle: "Explore Cluster", - }); - } catch (error) { - if (requestToken !== galleryRequestToken) return; - set({ - images: [], - totalImages: 0, - loadedCount: 0, - loadingImages: false, - imageLoadError: String(error), - collectionTitle: "Explore Cluster", - }); - } - }, - - searchForTag: (tag) => { - set({ activeView: "gallery", search: `/t ${tag}`, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, similarFolderId: null, imageLoadError: null }); - void get().loadImages(true); - }, - - loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null, albumId = null) => { - const requestToken = ++galleryRequestToken; - const offset = reset ? 0 : get().loadedCount; - const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder"; - // Album scope drives results off album membership, so the folder query is null. - const queryFolderId = albumId !== null ? null : folderId ?? null; - set((state) => ({ - images: reset ? [] : state.images, - loadedCount: reset ? 0 : state.loadedCount, - loadingImages: true, - collectionTitle: "Similar Images", - imageLoadError: null, - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarFolderId: queryFolderId, - similarScope, - // Force the gallery grid so results (and the bulk bar) render regardless - // of which view the search was launched from. - activeView: "gallery", - gallerySelectedIds: reset ? new Set() : state.gallerySelectedIds, - selectedAlbumId: null, - galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey, - })); - - try { - const result = await invoke("find_similar_images", { - params: { - image_id: imageId, - folder_id: queryFolderId, - album_id: albumId, - offset, - limit: PAGE_SIZE, - threshold: SIMILAR_DISTANCE_THRESHOLD, - }, - }); - - if (requestToken !== galleryRequestToken) return; - - set((state) => { - const nextImages = reset ? result.images : [...state.images, ...result.images]; - const nextLoadedCount = nextImages.length; - return { - images: nextImages, - totalImages: result.has_more ? nextLoadedCount + 1 : nextLoadedCount, - loadedCount: nextLoadedCount, - loadingImages: false, - imageLoadError: null, - collectionTitle: "Similar Images", - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarHasMore: result.has_more, - similarFolderId: queryFolderId, - similarScope, - selectedImage: reset ? null : state.selectedImage, - }; - }); - } catch (error) { - if (requestToken !== galleryRequestToken) return; - console.error("Failed to load similar images:", error); - set({ - images: [], - totalImages: 0, - loadedCount: 0, - loadingImages: false, - imageLoadError: String(error), - collectionTitle: "Similar Images", - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarHasMore: false, - similarFolderId: queryFolderId, - similarScope, - selectedImage: null, - }); - } - }, - - loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null, albumId = null) => { - const requestToken = ++galleryRequestToken; - const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder"; - const queryFolderId = albumId !== null ? null : folderId ?? null; - set((state) => ({ - images: [], - loadedCount: 0, - loadingImages: true, - collectionTitle: "Region Search Results", - imageLoadError: null, - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarFolderId: queryFolderId, - similarCrop: crop, - similarScope, - // Force the gallery grid so results (and the bulk bar) render regardless - // of which view the search was launched from. - activeView: "gallery", - gallerySelectedIds: new Set(), - selectedAlbumId: null, - galleryScrollResetKey: state.galleryScrollResetKey + 1, - selectedImage: null, - })); - - try { - const result = await invoke("find_similar_by_region", { - params: { - image_id: imageId, - crop_x: crop.x, - crop_y: crop.y, - crop_w: crop.w, - crop_h: crop.h, - folder_id: queryFolderId, - album_id: albumId, - offset: 0, - limit: PAGE_SIZE, - }, - }); - - if (requestToken !== galleryRequestToken) return; - - set({ - images: result.images, - totalImages: result.has_more ? result.images.length + 1 : result.images.length, - loadedCount: result.images.length, - loadingImages: false, - imageLoadError: null, - collectionTitle: "Region Search Results", - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarHasMore: result.has_more, - similarFolderId: queryFolderId, - similarCrop: crop, - similarScope, - }); - } catch (error) { - if (requestToken !== galleryRequestToken) return; - console.error("Failed to load region search results:", error); - set({ - images: [], - totalImages: 0, - loadedCount: 0, - loadingImages: false, - imageLoadError: String(error), - collectionTitle: "Region Search Results", - similarSourceImageId: imageId, - similarSourceFolderId: sourceFolderId, - similarHasMore: false, - similarFolderId: queryFolderId, - similarCrop: crop, - similarScope, - selectedImage: null, - }); - } - }, - - // Decide the scope at launch: album when triggered from an album, else the - // current folder/all preference. Sets similarSourceAlbumId so the "Similar: - // Album" pill and scope toggle work afterward. - findSimilar: (imageId, sourceFolderId) => { - const { activeView, selectedAlbumId, similarScope } = get(); - const albumOrigin = activeView === "album" ? selectedAlbumId : null; - set({ similarSourceAlbumId: albumOrigin }); - // Respect the chosen scope; album is the default in an album view but the - // user can override to Folder/All before searching. - if (similarScope === "current_album" && albumOrigin !== null) { - return get().loadSimilarImages(imageId, null, true, sourceFolderId, albumOrigin); - } - const folderId = similarScope === "current_folder" ? sourceFolderId : null; - return get().loadSimilarImages(imageId, folderId, true, sourceFolderId, null); - }, - - findSimilarByRegion: (imageId, crop, sourceFolderId) => { - const { activeView, selectedAlbumId, similarScope } = get(); - const albumOrigin = activeView === "album" ? selectedAlbumId : null; - set({ similarSourceAlbumId: albumOrigin }); - if (similarScope === "current_album" && albumOrigin !== null) { - return get().loadSimilarByRegion(imageId, crop, null, sourceFolderId, albumOrigin); - } - const folderId = similarScope === "current_folder" ? sourceFolderId : null; - return get().loadSimilarByRegion(imageId, crop, folderId, sourceFolderId, null); - }, - - setSimilarScope: (similarScope) => { - set({ similarScope }); - const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get(); - if (similarSourceImageId === null) return; - const albumId = similarScope === "current_album" ? similarSourceAlbumId : null; - const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null; - if (collectionTitle === "Region Search Results" && similarCrop !== null) { - void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId); - } else { - void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId, albumId); - } - }, - - suggestImageTags: async (imageId) => { - return invoke("suggest_image_tags", { - params: { image_id: imageId, limit: 2 }, - }); - }, - - loadCaptionModelStatus: async () => { - try { - const captionModelStatus = await invoke("get_caption_model_status"); - set({ captionModelStatus, captionModelError: null }); - } catch (error) { - set({ captionModelError: String(error) }); - } - }, - - loadCaptionAcceleration: async () => { - try { - const captionAcceleration = await invoke("get_caption_acceleration"); - set({ captionAcceleration }); - } catch (error) { - set({ captionModelError: String(error) }); - } - }, - - setCaptionAcceleration: async (acceleration) => { - const captionAcceleration = await invoke("set_caption_acceleration", { - params: { acceleration }, - }); - set({ captionAcceleration, captionRuntimeProbe: null }); - }, - - loadCaptionDetail: async () => { - try { - const captionDetail = await invoke("get_caption_detail"); - set({ captionDetail }); - } catch (error) { - set({ captionModelError: String(error) }); - } - }, - - setCaptionDetail: async (detail) => { - const captionDetail = await invoke("set_caption_detail", { - params: { detail }, - }); - set({ captionDetail, captionRuntimeProbe: null }); - }, - - prepareCaptionModel: async () => { - set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null }); - try { - const captionModelStatus = await invoke("prepare_caption_model"); - window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(captionModelStatus.ready)); - set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, aiCaptionsEnabled: captionModelStatus.ready }); - } catch (error) { - set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null }); - } - }, - - deleteCaptionModel: async () => { - set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null }); - try { - const captionModelStatus = await invoke("delete_caption_model"); - window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, "false"); - set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, captionRuntimeProbe: null, aiCaptionsEnabled: false }); - } catch (error) { - set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null }); - } - }, - - probeCaptionRuntime: async () => { - set({ captionRuntimeChecking: true, captionModelError: null }); - try { - const captionRuntimeProbe = await invoke("probe_caption_runtime"); - set({ captionRuntimeProbe, captionRuntimeChecking: false, captionModelError: null }); - } catch (error) { - set({ captionRuntimeChecking: false, captionModelError: String(error), captionRuntimeProbe: null }); - } - }, - - probeCaptionImage: async (imageId) => { - return invoke("probe_caption_image", { - params: { image_id: imageId }, - }); - }, - - generateCaptionForImage: async (imageId) => { - const updatedImage = await invoke("generate_caption_for_image", { - params: { image_id: imageId }, - }); - - set((state) => ({ - images: replaceImage(state.images, updatedImage, state.sort), - selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage, - })); - - return updatedImage; - }, - - queueCaptionJobs: async (folderId = get().selectedFolderId) => { - const queued = await invoke("queue_caption_jobs", { - params: { folder_id: folderId ?? null, image_id: null }, - }); - await get().loadBackgroundJobProgress(); - return queued; - }, - - queueCaptionForImage: async (imageId) => { - const queued = await invoke("queue_caption_jobs", { - params: { folder_id: null, image_id: imageId }, - }); - await get().loadBackgroundJobProgress(); - return queued; - }, - - clearCaptionJobs: async (folderId = get().selectedFolderId) => { - const cleared = await invoke("clear_caption_jobs", { - params: { folder_id: folderId ?? null }, - }); - await get().loadBackgroundJobProgress(); - return cleared; - }, - - resetGeneratedCaptions: async (folderId = get().selectedFolderId) => { - const reset = await invoke("reset_generated_captions", { - params: { folder_id: folderId ?? null }, - }); - await get().loadBackgroundJobProgress(); - await get().loadImages(true); - return reset; - }, - - setAiCaptionsEnabled: (aiCaptionsEnabled) => { - window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled)); - set({ aiCaptionsEnabled }); - }, - - setSettingsOpen: (settingsOpen) => set({ settingsOpen }), - setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }), - - loadTaggingQueueScope: async () => { - try { - const scope = await invoke("get_tagging_queue_scope"); - set({ taggingQueueScope: scope }); - } catch { - // silently fall back to in-memory default - } - }, - - setTaggingQueueScope: (taggingQueueScope) => { - set((state) => ({ - taggingQueueScope, - taggingQueueFolderIds: state.taggingQueueFolderIds, - })); - void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); - }, - - loadTaggingQueueFolderIds: async () => { - try { - const folderIds = await invoke("get_tagging_queue_folder_ids"); - set({ taggingQueueFolderIds: folderIds }); - } catch { - // silently fall back to in-memory default - } - }, - - toggleTaggingQueueFolder: (folderId) => { - set((state) => { - const next = state.taggingQueueFolderIds.includes(folderId) - ? state.taggingQueueFolderIds.filter((id) => id !== folderId) - : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); - void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {}); - return { taggingQueueFolderIds: next }; - }); - }, - - setTaggingQueueFolderIds: (taggingQueueFolderIds) => { - set({ taggingQueueFolderIds }); - void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {}); - }, - - loadMutedFolderIds: async () => { - try { - const folderIds = await invoke("get_muted_folder_ids"); - set({ mutedFolderIds: folderIds }); - } catch { - // fall back to in-memory default - } - }, - - toggleMutedFolder: (folderId) => { - set((state) => { - const next = state.mutedFolderIds.includes(folderId) - ? state.mutedFolderIds.filter((id) => id !== folderId) - : [...state.mutedFolderIds, folderId]; - void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {}); - return { mutedFolderIds: next }; - }); - }, - - loadNotificationsPaused: async () => { - try { - const paused = await invoke("get_notifications_paused"); - set({ notificationsPaused: paused }); - } catch { - // fall back to in-memory default - } - }, - - setNotificationsPaused: (paused) => { - set({ notificationsPaused: paused }); - void invoke("set_notifications_paused", { paused }).catch(() => {}); - }, - - loadRelatedTags: async (tag) => { - const trimmed = tag.trim(); - if (!trimmed) return []; - - const { selectedFolderId, relatedTagsByKey } = get(); - const key = `${selectedFolderId ?? "all"}:${trimmed}`; - if (relatedTagsByKey[key]) { - return relatedTagsByKey[key]; - } - - const entries = await invoke("get_related_tags", { - params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 }, - }); - set((state) => ({ - relatedTagsByKey: { - ...state.relatedTagsByKey, - [key]: entries, - }, - })); - return entries; - }, - - loadWorkerPausesPersist: async () => { - try { - const persist = await invoke("get_worker_pauses_persist"); - set({ workerPausesPersist: persist }); - } catch { - // fall back to in-memory default - } - }, - - setWorkerPausesPersist: (persist) => { - set({ workerPausesPersist: persist }); - void invoke("set_worker_pauses_persist", { persist }).catch(() => {}); - }, - - setTheme: (theme) => { - window.localStorage.setItem(THEME_KEY, theme); - document.documentElement.dataset.theme = theme; - set({ theme }); - }, - - setLightboxAutoplay: (enabled) => { - window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled)); - set({ lightboxAutoplay: enabled }); - }, - - setLightboxAutoMute: (enabled) => { - window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled)); - set({ lightboxAutoMute: enabled }); - }, - - setSlideshowIntervalSeconds: (seconds) => { - const next = Math.min(60, Math.max(3, Math.round(seconds))); - window.localStorage.setItem(SLIDESHOW_INTERVAL_KEY, String(next)); - set({ slideshowIntervalSeconds: next }); - }, - - setSlideshowOrder: (order) => { - window.localStorage.setItem(SLIDESHOW_ORDER_KEY, order); - set({ slideshowOrder: order }); - }, - - setSlideshowTransition: (transition) => { - window.localStorage.setItem(SLIDESHOW_TRANSITION_KEY, transition); - set({ slideshowTransition: transition }); - }, - - loadWorkerStates: async () => { - const folderIds = get().folders.map((folder) => folder.id); - if (folderIds.length === 0) { - set({ workerPaused: {} }); - return; - } - try { - const states = await invoke("get_worker_states", { folderIds }); - set({ - workerPaused: Object.fromEntries( - states.map((state) => [ - state.folder_id, - { - thumbnail: state.thumbnail_paused, - metadata: state.metadata_paused, - embedding: state.embedding_paused, - tagging: state.tagging_paused, - }, - ]), - ), - }); - } catch { - // leave the existing snapshot in place - } - }, - - setWorkerPaused: (folderId, worker, paused) => { - set((state) => { - const current = state.workerPaused[folderId] ?? { - thumbnail: false, - metadata: false, - embedding: false, - tagging: false, - }; - return { - workerPaused: { - ...state.workerPaused, - [folderId]: { ...current, [worker]: paused }, - }, - }; - }); - void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {}); - }, - - setAllWorkersPaused: (folderId, paused) => { - set((state) => ({ - workerPaused: { - ...state.workerPaused, - [folderId]: { thumbnail: paused, metadata: paused, embedding: paused, tagging: paused }, - }, - })); - for (const worker of WORKER_KEYS) { - void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {}); - } - }, - - loadAppVersion: async () => { - try { - set({ appVersion: await getVersion() }); - } catch { - // leave null; the UI falls back to a dash - } - try { - const variant = await invoke("get_build_variant"); - set({ buildVariant: variant === "cuda" ? "cuda" : "cpu" }); - } catch { - // leave null; the badge is hidden until known - } - }, - - checkForUpdates: async (options) => { - const quiet = options?.quiet ?? false; - const { updateStatus } = get(); - if (updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing") return; - - set({ updateStatus: "checking", updateError: null }); - try { - const update = await check(); - if (update) { - pendingUpdate = update; - set({ updateStatus: "available", updateVersion: update.version, updateDismissed: false }); - } else { - pendingUpdate = null; - set({ updateStatus: "upToDate", updateVersion: null }); - } - } catch (error) { - pendingUpdate = null; - if (quiet) { - // Launch-time check: stay silent on network/endpoint failures. - set({ updateStatus: "idle" }); - } else { - set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) }); - } - } - }, - - installUpdate: async () => { - const update = pendingUpdate; - if (!update || get().updateStatus !== "available") return; - - // Clearing the dismissed flag re-surfaces the progress toast: the user may - // have clicked "Later" on the prompt and then triggered the install from the - // title-bar button or Settings, and they should still see download progress. - set({ updateStatus: "downloading", updateProgress: null, updateError: null, updateDismissed: false }); - try { - let contentLength: number | null = null; - let downloaded = 0; - await update.downloadAndInstall((event) => { - switch (event.event) { - case "Started": - contentLength = event.data.contentLength ?? null; - set({ updateProgress: contentLength ? 0 : null }); - break; - case "Progress": - downloaded += event.data.chunkLength; - if (contentLength) { - set({ updateProgress: Math.min(downloaded / contentLength, 1) }); - } - break; - case "Finished": - set({ updateStatus: "installing", updateProgress: 1 }); - break; - } - }); - await relaunch(); - } catch (error) { - set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) }); - } - }, - - dismissUpdate: () => set({ updateDismissed: true }), - - initWhatsNew: async () => { - try { - const current = await getVersion(); - const lastSeen = (await invoke("get_last_seen_version")) || null; - // Already greeted this version — nothing to do, and no need to rewrite. - if (lastSeen === current) return; - - let shouldShow: boolean; - if (lastSeen) { - // A recorded earlier version means this launch is a genuine upgrade. - shouldShow = true; - } else { - // No record yet. Fresh installs are covered by the welcome tour, so only - // greet users who have already completed onboarding (i.e. upgraded into - // this feature) rather than someone opening the app for the first time. - shouldShow = await invoke("get_onboarding_completed").catch(() => false); - } - - // Only surface the prompt if we actually have notes for this version. - if (shouldShow && getChangelogForVersion(current)) { - set({ whatsNewToast: current }); - } - await invoke("set_last_seen_version", { version: current }).catch(() => {}); - } catch { - // Non-fatal: the greeting is a nicety, never block startup on it. - } - }, - - openWhatsNew: () => set({ whatsNewOpen: true, whatsNewToast: null }), - - closeWhatsNew: () => set({ whatsNewOpen: false }), - - dismissWhatsNewToast: () => set({ whatsNewToast: null }), - - loadFfmpegStatus: async () => { - try { - const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>( - "get_ffmpeg_status", - ); - if (status.installed) { - set({ ffmpegStatus: "installed" }); - } else if (status.failed) { - // The download failed before our event listener attached — surface - // the error state so the retry button is reachable. - set({ ffmpegStatus: "error", ffmpegError: "The download could not be completed. Check your connection and retry." }); - } else { - // Not installed and possibly not downloading yet — the provision - // thread starts with the app, so treat the gap as "starting" and let - // the first ffmpeg-progress event settle the real state. - set({ ffmpegStatus: "starting" }); - } - } catch { - // leave "unknown"; events will correct it - } - }, - - retryFfmpegDownload: async () => { - set({ ffmpegStatus: "starting", ffmpegError: null, ffmpegProgress: null }); - try { - await invoke("retry_ffmpeg_download"); - } catch (error) { - set({ ffmpegStatus: "error", ffmpegError: error instanceof Error ? error.message : String(error) }); - } - }, - - loadOnboardingCompleted: async () => { - try { - const completed = await invoke("get_onboarding_completed"); - set( - completed - ? { onboardingCompleted: true } - : { onboardingCompleted: false, onboardingOpen: true, onboardingStep: 0 }, - ); - } catch { - // If the flag can't be read, don't trap the user in onboarding. - set({ onboardingCompleted: true }); - } - }, - - completeOnboarding: () => { - set({ onboardingOpen: false, onboardingCompleted: true }); - void invoke("set_onboarding_completed", { completed: true }).catch(() => {}); - }, - - openOnboarding: () => set({ onboardingOpen: true, onboardingStep: 0 }), - - setOnboardingStep: (step) => set({ onboardingStep: step }), - - openAppDataFolder: async () => { - await invoke("open_app_data_folder"); - }, - - getDatabaseInfo: () => invoke("get_database_info"), - - vacuumDatabase: () => invoke("vacuum_database"), - - rebuildSemanticIndex: () => invoke("rebuild_semantic_index"), - - getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), - - cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), - - loadTaggerModelStatus: async () => { - try { - const taggerModelStatus = await invoke("get_tagger_model_status"); - set({ taggerModelStatus, taggerModelError: null }); - } catch (error) { - set({ taggerModelError: String(error) }); - } - }, - - loadTaggerAcceleration: async () => { - try { - const taggerAcceleration = await invoke("get_tagger_acceleration"); - set({ taggerAcceleration }); - } catch (error) { - set({ taggerModelError: String(error) }); - } - }, - - setTaggerAcceleration: async (acceleration) => { - const taggerAcceleration = await invoke("set_tagger_acceleration", { - params: { acceleration }, - }); - set({ taggerAcceleration, taggerRuntimeProbe: null }); - }, - - loadTaggerModel: async () => { - try { - const taggerModel = await invoke("get_tagger_model"); - // Never clobber the valid default with a missing/blank backend response. - if (taggerModel) set({ taggerModel }); - } catch (error) { - set({ taggerModelError: String(error) }); - } - }, - - setTaggerModel: async (model) => { - const taggerModel = await invoke("set_tagger_model", { - params: { model }, - }); - // Switching models changes both readiness and the active threshold setting, - // so refresh them together for the selected model. - try { - const [taggerModelStatus, taggerThreshold] = await Promise.all([ - invoke("get_tagger_model_status"), - invoke("get_tagger_threshold"), - ]); - set({ taggerModel, taggerModelStatus, taggerThreshold, taggerModelError: null, taggerRuntimeProbe: null }); - } catch (error) { - set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) }); - } - }, - - loadTaggerThreshold: async () => { - try { - const taggerThreshold = await invoke("get_tagger_threshold"); - set({ taggerThreshold }); - } catch (error) { - set({ taggerModelError: String(error) }); - } - }, - - setTaggerThreshold: async (threshold, model) => { - const taggerThreshold = await invoke("set_tagger_threshold", { - params: { threshold, model }, - }); - if (!model || get().taggerModel === model) { - set({ taggerThreshold }); - } - }, - - loadTaggerBatchSize: async () => { - try { - const taggerBatchSize = await invoke("get_tagger_batch_size"); - set({ taggerBatchSize }); - } catch (error) { - set({ taggerModelError: String(error) }); - } - }, - - setTaggerBatchSize: async (batchSize) => { - const taggerBatchSize = await invoke("set_tagger_batch_size", { - params: { batch_size: batchSize }, - }); - set({ taggerBatchSize }); - }, - - prepareTaggerModel: async () => { - set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null }); - try { - const taggerModelStatus = await invoke("prepare_tagger_model"); - set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null }); - } catch (error) { - set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null }); - } - }, - - deleteTaggerModel: async () => { - set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null }); - try { - const taggerModelStatus = await invoke("delete_tagger_model"); - set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, taggerRuntimeProbe: null }); - } catch (error) { - set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null }); - } - }, - - probeTaggerRuntime: async () => { - set({ taggerRuntimeChecking: true, taggerModelError: null }); - try { - const taggerRuntimeProbe = await invoke("probe_tagger_runtime"); - set({ taggerRuntimeProbe, taggerRuntimeChecking: false, taggerModelError: null }); - } catch (error) { - set({ taggerRuntimeChecking: false, taggerModelError: String(error), taggerRuntimeProbe: null }); - } - }, - - queueTaggingJobs: async (folderId = get().selectedFolderId) => { - const queued = await invoke("queue_tagging_jobs", { - params: { folder_id: folderId ?? null, image_id: null }, - }); - await get().loadBackgroundJobProgress(); - return queued; - }, - - queueTaggingJobsForFolders: async (folderIds) => { - const queued = await invoke("queue_tagging_jobs", { - params: { folder_id: null, folder_ids: folderIds, image_id: null }, - }); - await get().loadBackgroundJobProgress(); - return queued; - }, - - queueTaggingForImage: async (imageId) => { - const queued = await invoke("queue_tagging_jobs", { - params: { folder_id: null, image_id: imageId }, - }); - await get().loadBackgroundJobProgress(); - return queued; - }, - - clearTaggingJobs: async (folderId = get().selectedFolderId) => { - const cleared = await invoke("clear_tagging_jobs", { - params: { folder_id: folderId ?? null }, - }); - await get().loadBackgroundJobProgress(); - return cleared; - }, - - clearTaggingJobsForFolders: async (folderIds) => { - const cleared = await invoke("clear_tagging_jobs", { - params: { folder_id: null, folder_ids: folderIds }, - }); - await get().loadBackgroundJobProgress(); - return cleared; - }, - - resetAiTags: async (folderId = get().selectedFolderId) => { - const reset = await invoke("reset_ai_tags", { - params: { folder_id: folderId ?? null, folder_ids: null }, - }); - set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); - await get().loadBackgroundJobProgress(); - await get().loadImages(true); - return reset; - }, - - resetAiTagsForFolders: async (folderIds) => { - const reset = await invoke("reset_ai_tags", { - params: { folder_id: null, folder_ids: folderIds }, - }); - set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); - await get().loadBackgroundJobProgress(); - await get().loadImages(true); - return reset; - }, - - getImageTags: async (imageId) => { - return invoke("get_image_tags", { - params: { image_id: imageId }, - }); - }, - - addUserTag: async (imageId, tag) => { - const result = await invoke("add_user_tag", { - params: { image_id: imageId, tag }, - }); - // Invalidate explore tags cache so new tag appears immediately - set({ exploreTagsFolderId: undefined }); - return result; - }, - - removeTag: async (tagId) => { - await invoke("remove_tag", { - params: { tag_id: tagId }, - }); - // Invalidate explore tags cache so removed tag disappears immediately - set({ exploreTagsFolderId: undefined }); - }, - - getImageExif: async (imageId) => { - return invoke("get_image_exif", { params: { image_id: imageId } }); - }, - - renameTag: async (from, to) => { - await invoke("rename_tag", { params: { from, to } }); - // Tag content changed — invalidate the explore-tags and visual-cluster caches. - // Keep the current tag list visible while the refresh runs so manager UI - // state such as filtering and sorting is not lost to a loading remount. - set({ - exploreTagsFolderId: undefined, - visualClusterFolderId: undefined, - visualClusterEntries: [], - }); - const parsed = parseSearchValue(get().search); - if (parsed.mode === "tag" && parsed.query === from) { - // An active tag-search points at the old name — repoint it so the gallery - // refreshes instead of showing stale results for a tag that no longer exists. - get().setSearch(`/t ${to}`); - } else if (get().activeView === "explore") { - await get().loadExploreTags(); - } - }, - - deleteTag: async (tag) => { - const removed = await invoke("delete_tag", { params: { tag } }); - // Keep the current tag list visible while the refresh runs so manager UI - // state such as filtering and sorting is not lost to a loading remount. - set({ - exploreTagsFolderId: undefined, - visualClusterFolderId: undefined, - visualClusterEntries: [], - }); - const parsed = parseSearchValue(get().search); - if (parsed.mode === "tag" && parsed.query === tag) { - // The searched tag is gone — reload so the now-empty result is reflected. - void get().loadImages(true); - } else if (get().activeView === "explore") { - await get().loadExploreTags(); - } - return removed; - }, - - // ── Gallery multi-select (Feature A) ────────────────────────────────────── - - toggleGallerySelected: (imageId) => { - set((state) => { - const next = new Set(state.gallerySelectedIds); - if (next.has(imageId)) next.delete(imageId); - else next.add(imageId); - return { gallerySelectedIds: next }; - }); - }, - - selectAllGallery: () => { - set((state) => ({ gallerySelectedIds: new Set(state.images.map((image) => image.id)) })); - }, - - clearGallerySelection: () => set({ gallerySelectedIds: new Set() }), - - bulkSetFavorite: async (favorite) => { - const ids = Array.from(get().gallerySelectedIds); - if (ids.length === 0) return; - const updated = await invoke("bulk_update_details", { - params: { image_ids: ids, favorite, rating: null }, - }); - set((state) => { - const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id); - // Derived collections keep their relevance order (replace in place); only - // the real sorted gallery re-sorts. - return { - images: isDerivedCollectionTitle(state.collectionTitle) - ? replaceExistingImages(state.images, updated) - : mergeImages(state.images, updated, state.sort), - selectedImage: match ?? state.selectedImage, - }; - }); - }, - - bulkSetRating: async (rating) => { - const ids = Array.from(get().gallerySelectedIds); - if (ids.length === 0) return; - const updated = await invoke("bulk_update_details", { - params: { image_ids: ids, favorite: null, rating }, - }); - set((state) => { - const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id); - return { - images: isDerivedCollectionTitle(state.collectionTitle) - ? replaceExistingImages(state.images, updated) - : mergeImages(state.images, updated, state.sort), - selectedImage: match ?? state.selectedImage, - }; - }); - }, - - bulkAddTags: async (tags) => { - const ids = Array.from(get().gallerySelectedIds); - const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0); - if (ids.length === 0 || cleaned.length === 0) return; - await invoke("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } }); - // New tags landed — invalidate Explore tag caches. - set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); - }, - - bulkRemoveTag: async (tag) => { - const ids = Array.from(get().gallerySelectedIds); - if (ids.length === 0 || !tag.trim()) return; - await invoke("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } }); - set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); - }, - - bulkDeleteSelected: async () => { - const ids = Array.from(get().gallerySelectedIds); - if (ids.length === 0) return 0; - const affectedFolderIds = new Set( - get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id), - ); - const succeededIds = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); - const succeededSet = new Set(succeededIds); - set((state) => ({ - // Only remove images confirmed deleted — failed files remain selected for retry. - images: state.images.filter((image) => !succeededSet.has(image.id)), - loadedCount: state.images.filter((image) => !succeededSet.has(image.id)).length, - totalImages: Math.max(0, state.totalImages - succeededIds.length), - gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))), - // Deletion changes tag/duplicate/album aggregates. - visualClusterFolderId: undefined, - visualClusterEntries: [], - exploreTagsFolderId: undefined, - })); - // The DB cascade already removed these from album_images; refresh counts/covers. - void get().loadAlbums(); - await invoke("invalidate_duplicate_scan_cache", { folderId: null }); - for (const folderId of affectedFolderIds) { - await invoke("invalidate_duplicate_scan_cache", { folderId }); - } - return succeededIds.length; - }, - - // ── Albums (Feature B) ──────────────────────────────────────────────────── - - loadAlbums: async () => { - const albums = await invoke("list_albums"); - set({ albums, albumsLoaded: true }); - }, - - createAlbum: async (name) => { - const album = await invoke("create_album", { params: { name } }); - await get().loadAlbums(); - return album; - }, - - renameAlbum: async (albumId, name) => { - await invoke("rename_album", { params: { album_id: albumId, new_name: name } }); - await get().loadAlbums(); - }, - - deleteAlbum: async (albumId) => { - await invoke("delete_album", { params: { album_id: albumId } }); - // If the deleted album is being viewed, drop back to All Media. - if (get().activeView === "album" && get().selectedAlbumId === albumId) { - set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null }); - void get().loadImages(true); - } - await get().loadAlbums(); - }, - - deleteAlbums: async (albumIds) => { - if (albumIds.length === 0) return; - await invoke("delete_albums", { params: { album_ids: albumIds } }); - // If a deleted album is being viewed, drop back to All Media. - if (get().activeView === "album" && get().selectedAlbumId !== null && albumIds.includes(get().selectedAlbumId!)) { - set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null }); - void get().loadImages(true); - } - await get().loadAlbums(); - }, - - reorderAlbums: async (albumIds) => { - const previous = get().albums; - const byId = new Map(previous.map((album) => [album.id, album])); - const albums = albumIds - .map((id, index) => { - const album = byId.get(id); - return album ? { ...album, sort_order: index + 1 } : null; - }) - .filter((album): album is Album => album !== null); - set({ albums }); - try { - await invoke("reorder_albums", { params: { album_ids: albumIds } }); - } catch (error) { - set({ albums: previous }); - throw error; - } - }, - - addToAlbum: async (albumId, imageIds) => { - if (imageIds.length === 0) return 0; - const added = await invoke("add_images_to_album", { - params: { album_id: albumId, image_ids: imageIds }, - }); - await get().loadAlbums(); - return added; - }, - - removeFromAlbum: async (albumId, imageIds) => { - if (imageIds.length === 0) return; - await invoke("remove_images_from_album", { - params: { album_id: albumId, image_ids: imageIds }, - }); - // If viewing this album, splice the removed images out immediately. - if (get().activeView === "album" && get().selectedAlbumId === albumId) { - const removed = new Set(imageIds); - set((state) => { - const nextImages = state.images.filter((image) => !removed.has(image.id)); - // Decrement by what was actually on screen, not the requested count — - // some ids may live beyond the loaded page. - const removedFromView = state.images.length - nextImages.length; - return { - images: nextImages, - loadedCount: nextImages.length, - totalImages: Math.max(0, state.totalImages - removedFromView), - gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !removed.has(id))), - }; - }); - } - await get().loadAlbums(); - }, - - viewAlbum: (albumId) => { - const requestToken = ++galleryRequestToken; - const album = get().albums.find((entry) => entry.id === albumId); - const sort = get().sort; - set((state) => ({ - activeView: "album", - selectedAlbumId: albumId, - search: "", - images: [], - totalImages: album?.image_count ?? 0, - loadedCount: 0, - loadingImages: true, - collectionTitle: album?.name ?? "Album", - imageLoadError: null, - similarSourceImageId: null, - similarSourceFolderId: null, - similarSourceAlbumId: albumId, - similarScope: "current_album", - similarHasMore: false, - similarFolderId: null, - similarCrop: null, - gallerySelectedIds: new Set(), - galleryScrollResetKey: state.galleryScrollResetKey + 1, - })); - - void (async () => { - try { - const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { - params: { album_id: albumId, sort, offset: 0, limit: PAGE_SIZE }, - }); - if (requestToken !== galleryRequestToken) return; - set({ - images: result.images, - totalImages: result.total, - loadedCount: result.images.length, - loadingImages: false, - imageLoadError: null, - collectionTitle: album?.name ?? "Album", - }); - } catch (error) { - if (requestToken !== galleryRequestToken) return; - set({ images: [], totalImages: 0, loadedCount: 0, loadingImages: false, imageLoadError: String(error) }); - } - })(); - }, - - loadDuplicateScanCache: async (folderId = null) => { - interface CacheResult { groups: DuplicateGroup[]; scanned_at: number } - const cached = await invoke("load_duplicate_scan_cache", { folderId: folderId ?? null }); - if (cached) { - set({ - duplicateGroups: cached.groups, - duplicateLastScanned: cached.scanned_at, - duplicateScanFolderId: folderId, - duplicateScanWarning: null, - }); - } - }, - - scanDuplicates: async (folderId = null) => { - const { listen } = await import("@tauri-apps/api/event"); - set({ - duplicateScanning: true, - duplicateGroups: [], - duplicateScanProgress: null, - duplicateScanError: null, - duplicateScanWarning: null, - duplicateSelectedIds: new Set(), - }); - const unlisten = await listen("duplicate_scan_progress", (event) => { - set({ duplicateScanProgress: event.payload }); - }); - try { - const result = await invoke("find_duplicates", { folderId: folderId ?? null }); - const warning = result.skipped_files > 0 - ? `${result.skipped_files.toLocaleString()} file${result.skipped_files === 1 ? "" : "s"} could not be read and were skipped.` - : null; - set({ - duplicateGroups: result.groups, - duplicateLastScanned: Math.floor(Date.now() / 1000), - duplicateScanFolderId: folderId, - duplicateScanWarning: warning, - }); - void notifyTaskComplete( - "Duplicate scan complete", - `${result.groups.length === 1 ? "Found 1 duplicate group." : `Found ${result.groups.length.toLocaleString()} duplicate groups.`}${warning ? ` ${warning}` : ""}`, - ); - } catch (e) { - set({ duplicateScanError: String(e) }); - } finally { - unlisten(); - set({ duplicateScanning: false }); - } - }, - - toggleDuplicateSelected: (imageId) => { - set((state) => { - const next = new Set(state.duplicateSelectedIds); - if (next.has(imageId)) next.delete(imageId); - else next.add(imageId); - return { duplicateSelectedIds: next }; - }); - }, - - selectAllDuplicates: (imageIds) => { - set((state) => { - const next = new Set(state.duplicateSelectedIds); - for (const id of imageIds) next.add(id); - return { duplicateSelectedIds: next }; - }); - }, - - selectKeepFirstAllGroups: () => { - const { duplicateGroups } = get(); - const toMark = new Set(); - for (const group of duplicateGroups) { - for (const img of group.images.slice(1)) toMark.add(img.id); - } - set({ duplicateSelectedIds: toMark }); - }, - - clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }), - - deleteSelectedDuplicates: async () => { - const { duplicateSelectedIds, duplicateGroups } = get(); - const ids = Array.from(duplicateSelectedIds); - if (ids.length === 0) return 0; - // Backend returns only the IDs that were actually removed from disk. - const succeededIds = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); - const succeededSet = new Set(succeededIds); - // Only remove images confirmed deleted — failed files remain visible so the user can retry. - set((state) => ({ - duplicateSelectedIds: new Set(), - duplicateGroups: state.duplicateGroups - .map((g) => ({ ...g, images: g.images.filter((img) => !succeededSet.has(img.id)) })) - .filter((g) => g.images.length > 1), - })); - // Invalidate the persisted cache for every affected scope: - // - global "all" cache (always, since a folder-scoped deletion still makes the global result stale) - // - each folder that contained a deleted image (so a folder-scoped scan is also evicted) - const affectedFolderIds = new Set( - duplicateGroups - .flatMap((g) => g.images) - .filter((img) => succeededSet.has(img.id)) - .map((img) => img.folder_id), - ); - await invoke("invalidate_duplicate_scan_cache", { folderId: null }); // global - for (const folderId of affectedFolderIds) { - await invoke("invalidate_duplicate_scan_cache", { folderId }); - } - return succeededIds.length; - }, - - retryFailedEmbeddings: async (folderId) => { - await invoke("retry_failed_embeddings", { params: { folder_id: folderId } }); - await get().loadBackgroundJobProgress(); - }, - - updateImageDetails: async (imageId, updates) => { - const updatedImage = await invoke("update_image_details", { - params: { - image_id: imageId, - favorite: updates.favorite ?? null, - rating: updates.rating ?? null, - }, - }); - - set((state) => ({ - // Derived collections (similar / region / semantic / tag / album results) - // are ordered by relevance, not `state.sort` — re-sorting them on a - // favorite/rating change would scramble the results. Replace in place - // there; only the real sorted gallery re-sorts. - images: isDerivedCollectionTitle(state.collectionTitle) - ? replaceExistingImages(state.images, [updatedImage]) - : replaceImage(state.images, updatedImage, state.sort), - selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage, - })); - }, - - subscribeToProgress: async () => { - const unlistenProgress = await listen("index-progress", (event) => { - const progress = event.payload; - const previous = get().indexingProgress[progress.folder_id]; - set((state) => ({ - indexingProgress: { - ...state.indexingProgress, - [progress.folder_id]: progress, - }, - })); - - if (progress.done) { - if ( - previous && - !previous.done && - progress.total > 0 && - progress.indexed >= progress.total - ) { - const { notificationsPaused, mutedFolderIds } = get(); - if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) { - const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name; - void notifyTaskComplete( - "Folder scan complete", - folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.", - ); - } - } - void get().loadFolders(); - void get().loadBackgroundJobProgress(); - if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) { - void get().loadImages(true); - } - - setTimeout(() => { - set((state) => { - const next = { ...state.indexingProgress }; - delete next[progress.folder_id]; - return { indexingProgress: next }; - }); - }, 2000); - } - }); - - const unlistenMediaJobs = await listen("media-job-progress", (event) => { - const previousProgress = get().mediaJobProgress; - - for (const progress of event.payload.progress) { - const previous = previousProgress[progress.folder_id]; - if (!previous) continue; - - const { notificationsPaused, mutedFolderIds } = get(); - const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id); - const folderName = - get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder"; - - // Embeddings — debounced so rapid file additions don't fire per-file. - const embeddingKey = `${progress.folder_id}:embedding`; - if (!suppressed) { - if (previous.embedding_pending > 0 && progress.embedding_pending === 0) { - clearTimeout(notificationTimers.get(embeddingKey)); - const failureDetail = - progress.embedding_failed > 0 - ? ` ${progress.embedding_failed.toLocaleString()} failed.` - : ""; - const body = `${folderName} finished generating embeddings.${failureDetail}`; - notificationTimers.set(embeddingKey, setTimeout(() => { - notificationTimers.delete(embeddingKey); - void notifyTaskComplete("Embeddings complete", body); - }, NOTIFICATION_DEBOUNCE_MS)); - } else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) { - // More jobs queued — cancel the pending notification. - clearTimeout(notificationTimers.get(embeddingKey)); - notificationTimers.delete(embeddingKey); - } - } - - // Tagging — same debounce pattern. - const taggingKey = `${progress.folder_id}:tagging`; - if (!suppressed) { - if (previous.tagging_pending > 0 && progress.tagging_pending === 0) { - clearTimeout(notificationTimers.get(taggingKey)); - const failureDetail = - progress.tagging_failed > 0 - ? ` ${progress.tagging_failed.toLocaleString()} failed.` - : ""; - const body = `${folderName} finished generating tags.${failureDetail}`; - notificationTimers.set(taggingKey, setTimeout(() => { - notificationTimers.delete(taggingKey); - void notifyTaskComplete("AI tagging complete", body); - }, NOTIFICATION_DEBOUNCE_MS)); - const state = get(); - if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) { - // New tags landed — refresh the Explore tag cloud after the worker - // settles instead of waiting for the user to revisit Explore. - set({ exploreTagsFolderId: undefined }); - if (state.activeView === "explore" && state.exploreMode === "tags") { - scheduleExploreTagRefresh(() => { - void get().loadExploreTags({ force: true }); - }, EXPLORE_TAG_REFRESH_IDLE_MS); - } - } - } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { - clearTimeout(notificationTimers.get(taggingKey)); - notificationTimers.delete(taggingKey); - } - } - } - - set((state) => { - const next = { ...state.mediaJobProgress }; - for (const progress of event.payload.progress) { - next[progress.folder_id] = progress; - } - return { mediaJobProgress: next }; - }); - }); - - const unlistenCaptionModelProgress = await listen("caption-model-progress", (event) => { - set({ - captionModelProgress: event.payload.done ? null : event.payload, - captionModelPreparing: !event.payload.done, - }); - }); - - const unlistenTaggerModelProgress = await listen("tagger-model-progress", (event) => { - set({ - taggerModelProgress: event.payload.done ? null : event.payload, - taggerModelPreparing: !event.payload.done, - }); - if (event.payload.done) { - void get().loadTaggerModelStatus(); - } - }); - - const unlistenImages = await listen("indexed-images", (event) => { - const batch = event.payload; - - set((state) => { - // Album view holds a fixed membership set; newly-indexed files never - // auto-join it. Guarding on activeView also covers the brief window - // where collectionTitle is null mid sort-change in an album. - if ( - isDerivedCollectionTitle(state.collectionTitle) || - state.activeView === "explore" || - state.activeView === "album" || - state.colorFilter !== null - ) { - return state; - } - - const visibleImages = batch.images.filter((image) => - matchesFilters( - image, - state.selectedFolderId, - state.mediaFilter, - state.favoritesOnly, - state.minimumRating, - state.failedEmbeddingsOnly, - state.failedTaggingOnly, - state.search, - ), - ); - - if (visibleImages.length === 0) { - return state; - } - - const newVisibleCount = countNewImages(state.images, visibleImages); - const visibleWindow = Math.max(state.loadedCount, PAGE_SIZE); - const images = mergeIntoVisibleWindow(state.images, visibleImages, state.sort, visibleWindow); - return { - images, - loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)), - totalImages: Math.max(state.totalImages + newVisibleCount, images.length), - }; - }); - }); - - const unlistenThumbnails = await listen("media-updated", (event) => { - const batch = event.payload; - const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); - const state = get(); - if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) { - set({ exploreTagsFolderId: undefined }); - if (state.activeView === "explore" && state.exploreMode === "tags") { - const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId) - ? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS - : EXPLORE_TAG_REFRESH_IDLE_MS; - scheduleExploreTagRefresh(() => { - void get().loadExploreTags({ force: true }); - }, delay); - } - } - - set((state) => { - const selectedImageUpdate = - state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id) - ? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage - : state.selectedImage; - - // Album view holds already-loaded images; paint thumbnail/metadata - // fills in place (without re-sorting) so tiles refresh while browsing. - if (state.activeView === "album") { - return { - images: replaceExistingImages(state.images, batch.images), - selectedImage: selectedImageUpdate, - }; - } - - if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") { - return { selectedImage: selectedImageUpdate }; - } - - const visibleImages = batch.images.filter((image) => - matchesFilters( - image, - state.selectedFolderId, - state.mediaFilter, - state.favoritesOnly, - state.minimumRating, - state.failedEmbeddingsOnly, - state.failedTaggingOnly, - state.search, - ), - ); - - if (visibleImages.length === 0) { - return { selectedImage: selectedImageUpdate }; - } - - return { - images: replaceExistingImages(state.images, visibleImages), - selectedImage: selectedImageUpdate, - }; - }); - }); - - const unlistenWatcherDeleted = await listen("watcher-deleted", (event) => { - const deletedIds = new Set(event.payload); - set((state) => { - const removed = state.images.filter((img) => deletedIds.has(img.id)).length; - const images = state.images.filter((img) => !deletedIds.has(img.id)); - const selectedImage = - state.selectedImage && deletedIds.has(state.selectedImage.id) - ? null - : state.selectedImage; - return { - images, - totalImages: Math.max(0, state.totalImages - removed), - loadedCount: Math.max(0, state.loadedCount - removed), - selectedImage, - }; - }); - }); - - const unlistenFolderCounts = await listen("folder-counts-changed", () => { - void get().loadFolders(); - }); - - const unlistenFfmpegProgress = await listen("ffmpeg-progress", (event) => { - const payload = event.payload; - switch (payload.phase) { - case "starting": - set({ ffmpegStatus: "starting", ffmpegError: null }); - break; - case "downloading": - set({ - ffmpegStatus: "downloading", - ffmpegProgress: - payload.downloaded_bytes !== null && payload.total_bytes !== null - ? { downloaded_bytes: payload.downloaded_bytes, total_bytes: payload.total_bytes } - : null, - }); - break; - case "unpacking": - set({ ffmpegStatus: "unpacking" }); - break; - case "done": - set({ ffmpegStatus: "installed", ffmpegProgress: null, ffmpegError: null }); - break; - case "error": - set({ ffmpegStatus: "error", ffmpegError: payload.error ?? "Download failed" }); - break; - } - }); - - const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>( - "color-backfill-progress", - (event) => { - set({ colorBackfill: event.payload.done ? null : event.payload }); - }, - ); - - return () => { - if (exploreTagRefreshTimer) { - clearTimeout(exploreTagRefreshTimer); - exploreTagRefreshTimer = null; - } - unlistenProgress(); - unlistenMediaJobs(); - unlistenCaptionModelProgress(); - unlistenTaggerModelProgress(); - unlistenImages(); - unlistenThumbnails(); - unlistenWatcherDeleted(); - unlistenFolderCounts(); - unlistenFfmpegProgress(); - unlistenColorBackfill(); - }; - }, -})); - -appDataDir().then(async (dir) => { - useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails")); -}); diff --git a/src/store/albumSlice.ts b/src/store/albumSlice.ts new file mode 100644 index 0000000..93bbb2b --- /dev/null +++ b/src/store/albumSlice.ts @@ -0,0 +1,161 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import type { GalleryStore } from "./index"; +import { PAGE_SIZE, isCurrentGalleryRequest, nextGalleryRequestToken } from "./helpers"; +import type { Album, ImageRecord } from "./types"; + +export interface AlbumSlice { + albums: Album[]; + albumsLoaded: boolean; + selectedAlbumId: number | null; + + loadAlbums: () => Promise; + createAlbum: (name: string) => Promise; + renameAlbum: (albumId: number, name: string) => Promise; + deleteAlbum: (albumId: number) => Promise; + deleteAlbums: (albumIds: number[]) => Promise; + reorderAlbums: (albumIds: number[]) => Promise; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + removeFromAlbum: (albumId: number, imageIds: number[]) => Promise; + viewAlbum: (albumId: number) => void; +} + +export const createAlbumSlice: StateCreator = (set, get) => ({ + albums: [], + albumsLoaded: false, + selectedAlbumId: null, + + loadAlbums: async () => { + const albums = await invoke("list_albums"); + set({ albums, albumsLoaded: true }); + }, + + createAlbum: async (name) => { + const album = await invoke("create_album", { params: { name } }); + await get().loadAlbums(); + return album; + }, + + renameAlbum: async (albumId, name) => { + await invoke("rename_album", { params: { album_id: albumId, new_name: name } }); + await get().loadAlbums(); + }, + + deleteAlbum: async (albumId) => { + await invoke("delete_album", { params: { album_id: albumId } }); + // If the deleted album is being viewed, drop back to All Media. + if (get().activeView === "album" && get().selectedAlbumId === albumId) { + set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null }); + void get().loadImages(true); + } + await get().loadAlbums(); + }, + + deleteAlbums: async (albumIds) => { + if (albumIds.length === 0) return; + await invoke("delete_albums", { params: { album_ids: albumIds } }); + // If a deleted album is being viewed, drop back to All Media. + if (get().activeView === "album" && get().selectedAlbumId !== null && albumIds.includes(get().selectedAlbumId!)) { + set({ activeView: "gallery", selectedAlbumId: null, collectionTitle: null }); + void get().loadImages(true); + } + await get().loadAlbums(); + }, + + reorderAlbums: async (albumIds) => { + const previous = get().albums; + const byId = new Map(previous.map((album) => [album.id, album])); + const albums = albumIds + .map((id, index) => { + const album = byId.get(id); + return album ? { ...album, sort_order: index + 1 } : null; + }) + .filter((album): album is Album => album !== null); + set({ albums }); + try { + await invoke("reorder_albums", { params: { album_ids: albumIds } }); + } catch (error) { + set({ albums: previous }); + throw error; + } + }, + + addToAlbum: async (albumId, imageIds) => { + if (imageIds.length === 0) return 0; + const added = await invoke("add_images_to_album", { + params: { album_id: albumId, image_ids: imageIds }, + }); + await get().loadAlbums(); + return added; + }, + + removeFromAlbum: async (albumId, imageIds) => { + if (imageIds.length === 0) return; + await invoke("remove_images_from_album", { + params: { album_id: albumId, image_ids: imageIds }, + }); + // If viewing this album, splice the removed images out immediately. + if (get().activeView === "album" && get().selectedAlbumId === albumId) { + const removed = new Set(imageIds); + set((state) => { + const nextImages = state.images.filter((image) => !removed.has(image.id)); + // Decrement by what was actually on screen, not the requested count — + // some ids may live beyond the loaded page. + const removedFromView = state.images.length - nextImages.length; + return { + images: nextImages, + loadedCount: nextImages.length, + totalImages: Math.max(0, state.totalImages - removedFromView), + gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !removed.has(id))), + }; + }); + } + await get().loadAlbums(); + }, + + viewAlbum: (albumId) => { + const requestToken = nextGalleryRequestToken(); + const album = get().albums.find((entry) => entry.id === albumId); + const sort = get().sort; + set((state) => ({ + activeView: "album", + selectedAlbumId: albumId, + search: "", + images: [], + totalImages: album?.image_count ?? 0, + loadedCount: 0, + loadingImages: true, + collectionTitle: album?.name ?? "Album", + imageLoadError: null, + similarSourceImageId: null, + similarSourceFolderId: null, + similarSourceAlbumId: albumId, + similarScope: "current_album", + similarHasMore: false, + similarFolderId: null, + similarCrop: null, + gallerySelectedIds: new Set(), + galleryScrollResetKey: state.galleryScrollResetKey + 1, + })); + + void (async () => { + try { + const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { + params: { album_id: albumId, sort, offset: 0, limit: PAGE_SIZE }, + }); + if (!isCurrentGalleryRequest(requestToken)) return; + set({ + images: result.images, + totalImages: result.total, + loadedCount: result.images.length, + loadingImages: false, + imageLoadError: null, + collectionTitle: album?.name ?? "Album", + }); + } catch (error) { + if (!isCurrentGalleryRequest(requestToken)) return; + set({ images: [], totalImages: 0, loadedCount: 0, loadingImages: false, imageLoadError: String(error) }); + } + })(); + }, +}); diff --git a/src/store/appSlice.ts b/src/store/appSlice.ts new file mode 100644 index 0000000..8a74106 --- /dev/null +++ b/src/store/appSlice.ts @@ -0,0 +1,320 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getVersion } from "@tauri-apps/api/app"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { check, Update } from "@tauri-apps/plugin-updater"; +import type { StateCreator } from "zustand"; +import { getChangelogForVersion } from "../changelog"; +import type { GalleryStore } from "./index"; +import type { FfmpegStatus, FolderWorkerStates, UpdateStatus, WorkerKey } from "./types"; +import { WORKER_KEYS } from "./types"; + +// The Update handle from the plugin carries the download method; it's not +// serializable state, so it lives outside the store. +let pendingUpdate: Update | null = null; + +export interface AppSlice { + appVersion: string | null; + buildVariant: "cpu" | "cuda" | null; + updateStatus: UpdateStatus; + updateVersion: string | null; + updateProgress: number | null; // 0..1 download progress, null while size unknown + updateError: string | null; + updateDismissed: boolean; + // "What's New" greeting after a version change. `whatsNewToast` holds the + // version to advertise in the corner toast (null = hidden); `whatsNewOpen` + // controls the full changelog modal. + whatsNewOpen: boolean; + whatsNewToast: string | null; + + ffmpegStatus: FfmpegStatus; + ffmpegProgress: { downloaded_bytes: number; total_bytes: number } | null; + ffmpegError: string | null; + onboardingCompleted: boolean | null; // null = not loaded yet + onboardingOpen: boolean; + onboardingStep: number; + + workerPausesPersist: boolean; + // Per-folder background-worker pause flags, shared by the BackgroundTasks + // bar and the sidebar folder context menu. + workerPaused: Record>; + + colorBackfill: { processed: number; total: number; done: boolean } | null; + + loadAppVersion: () => Promise; + checkForUpdates: (options?: { quiet?: boolean }) => Promise; + installUpdate: () => Promise; + dismissUpdate: () => void; + initWhatsNew: () => Promise; + openWhatsNew: () => void; + closeWhatsNew: () => void; + dismissWhatsNewToast: () => void; + loadFfmpegStatus: () => Promise; + retryFfmpegDownload: () => Promise; + loadOnboardingCompleted: () => Promise; + completeOnboarding: () => void; + openOnboarding: () => void; + setOnboardingStep: (step: number) => void; + loadWorkerPausesPersist: () => Promise; + setWorkerPausesPersist: (persist: boolean) => void; + loadWorkerStates: () => Promise; + setWorkerPaused: (folderId: number, worker: WorkerKey, paused: boolean) => void; + setAllWorkersPaused: (folderId: number, paused: boolean) => void; +} + +export const createAppSlice: StateCreator = (set, get) => ({ + appVersion: null, + buildVariant: null, + updateStatus: "idle", + updateVersion: null, + updateProgress: null, + updateError: null, + updateDismissed: false, + whatsNewOpen: false, + whatsNewToast: null, + + ffmpegStatus: "unknown", + ffmpegProgress: null, + ffmpegError: null, + onboardingCompleted: null, + onboardingOpen: false, + onboardingStep: 0, + + workerPausesPersist: false, + workerPaused: {}, + + colorBackfill: null, + + loadAppVersion: async () => { + try { + set({ appVersion: await getVersion() }); + } catch { + // leave null; the UI falls back to a dash + } + try { + const variant = await invoke("get_build_variant"); + set({ buildVariant: variant === "cuda" ? "cuda" : "cpu" }); + } catch { + // leave null; the badge is hidden until known + } + }, + + checkForUpdates: async (options) => { + const quiet = options?.quiet ?? false; + const { updateStatus } = get(); + if (updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing") return; + + set({ updateStatus: "checking", updateError: null }); + try { + const update = await check(); + if (update) { + pendingUpdate = update; + set({ updateStatus: "available", updateVersion: update.version, updateDismissed: false }); + } else { + pendingUpdate = null; + set({ updateStatus: "upToDate", updateVersion: null }); + } + } catch (error) { + pendingUpdate = null; + if (quiet) { + // Launch-time check: stay silent on network/endpoint failures. + set({ updateStatus: "idle" }); + } else { + set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) }); + } + } + }, + + installUpdate: async () => { + const update = pendingUpdate; + if (!update || get().updateStatus !== "available") return; + + // Clearing the dismissed flag re-surfaces the progress toast: the user may + // have clicked "Later" on the prompt and then triggered the install from the + // title-bar button or Settings, and they should still see download progress. + set({ updateStatus: "downloading", updateProgress: null, updateError: null, updateDismissed: false }); + try { + let contentLength: number | null = null; + let downloaded = 0; + await update.downloadAndInstall((event) => { + switch (event.event) { + case "Started": + contentLength = event.data.contentLength ?? null; + set({ updateProgress: contentLength ? 0 : null }); + break; + case "Progress": + downloaded += event.data.chunkLength; + if (contentLength) { + set({ updateProgress: Math.min(downloaded / contentLength, 1) }); + } + break; + case "Finished": + set({ updateStatus: "installing", updateProgress: 1 }); + break; + } + }); + await relaunch(); + } catch (error) { + set({ updateStatus: "error", updateError: error instanceof Error ? error.message : String(error) }); + } + }, + + dismissUpdate: () => set({ updateDismissed: true }), + + initWhatsNew: async () => { + try { + const current = await getVersion(); + const lastSeen = (await invoke("get_last_seen_version")) || null; + // Already greeted this version — nothing to do, and no need to rewrite. + if (lastSeen === current) return; + + let shouldShow: boolean; + if (lastSeen) { + // A recorded earlier version means this launch is a genuine upgrade. + shouldShow = true; + } else { + // No record yet. Fresh installs are covered by the welcome tour, so only + // greet users who have already completed onboarding (i.e. upgraded into + // this feature) rather than someone opening the app for the first time. + shouldShow = await invoke("get_onboarding_completed").catch(() => false); + } + + // Only surface the prompt if we actually have notes for this version. + if (shouldShow && getChangelogForVersion(current)) { + set({ whatsNewToast: current }); + } + await invoke("set_last_seen_version", { version: current }).catch(() => {}); + } catch { + // Non-fatal: the greeting is a nicety, never block startup on it. + } + }, + + openWhatsNew: () => set({ whatsNewOpen: true, whatsNewToast: null }), + + closeWhatsNew: () => set({ whatsNewOpen: false }), + + dismissWhatsNewToast: () => set({ whatsNewToast: null }), + + loadFfmpegStatus: async () => { + try { + const status = await invoke<{ installed: boolean; downloading: boolean; failed: boolean }>( + "get_ffmpeg_status", + ); + if (status.installed) { + set({ ffmpegStatus: "installed" }); + } else if (status.failed) { + // The download failed before our event listener attached — surface + // the error state so the retry button is reachable. + set({ ffmpegStatus: "error", ffmpegError: "The download could not be completed. Check your connection and retry." }); + } else { + // Not installed and possibly not downloading yet — the provision + // thread starts with the app, so treat the gap as "starting" and let + // the first ffmpeg-progress event settle the real state. + set({ ffmpegStatus: "starting" }); + } + } catch { + // leave "unknown"; events will correct it + } + }, + + retryFfmpegDownload: async () => { + set({ ffmpegStatus: "starting", ffmpegError: null, ffmpegProgress: null }); + try { + await invoke("retry_ffmpeg_download"); + } catch (error) { + set({ ffmpegStatus: "error", ffmpegError: error instanceof Error ? error.message : String(error) }); + } + }, + + loadOnboardingCompleted: async () => { + try { + const completed = await invoke("get_onboarding_completed"); + set( + completed + ? { onboardingCompleted: true } + : { onboardingCompleted: false, onboardingOpen: true, onboardingStep: 0 }, + ); + } catch { + // If the flag can't be read, don't trap the user in onboarding. + set({ onboardingCompleted: true }); + } + }, + + completeOnboarding: () => { + set({ onboardingOpen: false, onboardingCompleted: true }); + void invoke("set_onboarding_completed", { completed: true }).catch(() => {}); + }, + + openOnboarding: () => set({ onboardingOpen: true, onboardingStep: 0 }), + + setOnboardingStep: (step) => set({ onboardingStep: step }), + + loadWorkerPausesPersist: async () => { + try { + const persist = await invoke("get_worker_pauses_persist"); + set({ workerPausesPersist: persist }); + } catch { + // fall back to in-memory default + } + }, + + setWorkerPausesPersist: (persist) => { + set({ workerPausesPersist: persist }); + void invoke("set_worker_pauses_persist", { persist }).catch(() => {}); + }, + + loadWorkerStates: async () => { + const folderIds = get().folders.map((folder) => folder.id); + if (folderIds.length === 0) { + set({ workerPaused: {} }); + return; + } + try { + const states = await invoke("get_worker_states", { folderIds }); + set({ + workerPaused: Object.fromEntries( + states.map((state) => [ + state.folder_id, + { + thumbnail: state.thumbnail_paused, + metadata: state.metadata_paused, + embedding: state.embedding_paused, + tagging: state.tagging_paused, + }, + ]), + ), + }); + } catch { + // leave the existing snapshot in place + } + }, + + setWorkerPaused: (folderId, worker, paused) => { + set((state) => { + const current = state.workerPaused[folderId] ?? { + thumbnail: false, + metadata: false, + embedding: false, + tagging: false, + }; + return { + workerPaused: { + ...state.workerPaused, + [folderId]: { ...current, [worker]: paused }, + }, + }; + }); + void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {}); + }, + + setAllWorkersPaused: (folderId, paused) => { + set((state) => ({ + workerPaused: { + ...state.workerPaused, + [folderId]: { thumbnail: paused, metadata: paused, embedding: paused, tagging: paused }, + }, + })); + for (const worker of WORKER_KEYS) { + void invoke("set_worker_paused", { worker, folderId, paused }).catch(() => {}); + } + }, +}); diff --git a/src/store/captionSlice.ts b/src/store/captionSlice.ts new file mode 100644 index 0000000..1190c8b --- /dev/null +++ b/src/store/captionSlice.ts @@ -0,0 +1,185 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { initialAiCaptionsEnabled, replaceImage } from "./helpers"; +import type { GalleryStore } from "./index"; +import type { + CaptionAcceleration, + CaptionDetail, + CaptionModelProgress, + CaptionModelStatus, + CaptionRuntimeProbe, + CaptionVisionProbe, + ImageRecord, +} from "./types"; + +const AI_CAPTIONS_ENABLED_KEY = "phokus.aiCaptionsEnabled"; + +export interface CaptionSlice { + captionModelStatus: CaptionModelStatus | null; + captionModelPreparing: boolean; + captionModelError: string | null; + captionModelProgress: CaptionModelProgress | null; + captionRuntimeProbe: CaptionRuntimeProbe | null; + captionRuntimeChecking: boolean; + captionAcceleration: CaptionAcceleration; + captionDetail: CaptionDetail; + aiCaptionsEnabled: boolean; + + loadCaptionModelStatus: () => Promise; + prepareCaptionModel: () => Promise; + deleteCaptionModel: () => Promise; + probeCaptionRuntime: () => Promise; + probeCaptionImage: (imageId: number) => Promise; + generateCaptionForImage: (imageId: number) => Promise; + queueCaptionJobs: (folderId?: number | null) => Promise; + queueCaptionForImage: (imageId: number) => Promise; + clearCaptionJobs: (folderId?: number | null) => Promise; + resetGeneratedCaptions: (folderId?: number | null) => Promise; + loadCaptionAcceleration: () => Promise; + setCaptionAcceleration: (acceleration: CaptionAcceleration) => Promise; + loadCaptionDetail: () => Promise; + setCaptionDetail: (detail: CaptionDetail) => Promise; + setAiCaptionsEnabled: (enabled: boolean) => void; +} + +export const createCaptionSlice: StateCreator = (set, get) => ({ + captionModelStatus: null, + captionModelPreparing: false, + captionModelError: null, + captionModelProgress: null, + captionRuntimeProbe: null, + captionRuntimeChecking: false, + captionAcceleration: "auto", + captionDetail: "paragraph", + aiCaptionsEnabled: initialAiCaptionsEnabled(AI_CAPTIONS_ENABLED_KEY), + + loadCaptionModelStatus: async () => { + try { + const captionModelStatus = await invoke("get_caption_model_status"); + set({ captionModelStatus, captionModelError: null }); + } catch (error) { + set({ captionModelError: String(error) }); + } + }, + + loadCaptionAcceleration: async () => { + try { + const captionAcceleration = await invoke("get_caption_acceleration"); + set({ captionAcceleration }); + } catch (error) { + set({ captionModelError: String(error) }); + } + }, + + setCaptionAcceleration: async (acceleration) => { + const captionAcceleration = await invoke("set_caption_acceleration", { + params: { acceleration }, + }); + set({ captionAcceleration, captionRuntimeProbe: null }); + }, + + loadCaptionDetail: async () => { + try { + const captionDetail = await invoke("get_caption_detail"); + set({ captionDetail }); + } catch (error) { + set({ captionModelError: String(error) }); + } + }, + + setCaptionDetail: async (detail) => { + const captionDetail = await invoke("set_caption_detail", { + params: { detail }, + }); + set({ captionDetail, captionRuntimeProbe: null }); + }, + + prepareCaptionModel: async () => { + set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null }); + try { + const captionModelStatus = await invoke("prepare_caption_model"); + window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(captionModelStatus.ready)); + set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, aiCaptionsEnabled: captionModelStatus.ready }); + } catch (error) { + set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null }); + } + }, + + deleteCaptionModel: async () => { + set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null }); + try { + const captionModelStatus = await invoke("delete_caption_model"); + window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, "false"); + set({ captionModelStatus, captionModelPreparing: false, captionModelError: null, captionModelProgress: null, captionRuntimeProbe: null, aiCaptionsEnabled: false }); + } catch (error) { + set({ captionModelPreparing: false, captionModelError: String(error), captionModelProgress: null }); + } + }, + + probeCaptionRuntime: async () => { + set({ captionRuntimeChecking: true, captionModelError: null }); + try { + const captionRuntimeProbe = await invoke("probe_caption_runtime"); + set({ captionRuntimeProbe, captionRuntimeChecking: false, captionModelError: null }); + } catch (error) { + set({ captionRuntimeChecking: false, captionModelError: String(error), captionRuntimeProbe: null }); + } + }, + + probeCaptionImage: async (imageId) => { + return invoke("probe_caption_image", { + params: { image_id: imageId }, + }); + }, + + generateCaptionForImage: async (imageId) => { + const updatedImage = await invoke("generate_caption_for_image", { + params: { image_id: imageId }, + }); + + set((state) => ({ + images: replaceImage(state.images, updatedImage, state.sort), + selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage, + })); + + return updatedImage; + }, + + queueCaptionJobs: async (folderId = get().selectedFolderId) => { + const queued = await invoke("queue_caption_jobs", { + params: { folder_id: folderId ?? null, image_id: null }, + }); + await get().loadBackgroundJobProgress(); + return queued; + }, + + queueCaptionForImage: async (imageId) => { + const queued = await invoke("queue_caption_jobs", { + params: { folder_id: null, image_id: imageId }, + }); + await get().loadBackgroundJobProgress(); + return queued; + }, + + clearCaptionJobs: async (folderId = get().selectedFolderId) => { + const cleared = await invoke("clear_caption_jobs", { + params: { folder_id: folderId ?? null }, + }); + await get().loadBackgroundJobProgress(); + return cleared; + }, + + resetGeneratedCaptions: async (folderId = get().selectedFolderId) => { + const reset = await invoke("reset_generated_captions", { + params: { folder_id: folderId ?? null }, + }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + + setAiCaptionsEnabled: (aiCaptionsEnabled) => { + window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled)); + set({ aiCaptionsEnabled }); + }, +}); diff --git a/src/store/duplicateSlice.ts b/src/store/duplicateSlice.ts new file mode 100644 index 0000000..b4a32bf --- /dev/null +++ b/src/store/duplicateSlice.ts @@ -0,0 +1,142 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { notifyTaskComplete } from "../notifications"; +import type { GalleryStore } from "./index"; +import type { DuplicateGroup, DuplicateScanProgress, DuplicateScanResult } from "./types"; + +export interface DuplicateSlice { + duplicateGroups: DuplicateGroup[]; + duplicateScanning: boolean; + duplicateScanProgress: DuplicateScanProgress | null; + duplicateScanError: string | null; + duplicateScanWarning: string | null; + duplicateSelectedIds: Set; + duplicateLastScanned: number | null; // Unix timestamp (seconds) + duplicateScanFolderId: number | null | undefined; // undefined = never scanned + + loadDuplicateScanCache: (folderId?: number | null) => Promise; + scanDuplicates: (folderId?: number | null) => Promise; + toggleDuplicateSelected: (imageId: number) => void; + selectAllDuplicates: (imageIds: number[]) => void; + selectKeepFirstAllGroups: () => void; + clearDuplicateSelection: () => void; + deleteSelectedDuplicates: () => Promise; +} + +export const createDuplicateSlice: StateCreator = (set, get) => ({ + duplicateGroups: [], + duplicateScanning: false, + duplicateScanProgress: null, + duplicateScanError: null, + duplicateScanWarning: null, + duplicateSelectedIds: new Set(), + duplicateLastScanned: null, + duplicateScanFolderId: undefined, + + loadDuplicateScanCache: async (folderId = null) => { + interface CacheResult { groups: DuplicateGroup[]; scanned_at: number } + const cached = await invoke("load_duplicate_scan_cache", { folderId: folderId ?? null }); + if (cached) { + set({ + duplicateGroups: cached.groups, + duplicateLastScanned: cached.scanned_at, + duplicateScanFolderId: folderId, + duplicateScanWarning: null, + }); + } + }, + + scanDuplicates: async (folderId = null) => { + const { listen } = await import("@tauri-apps/api/event"); + set({ + duplicateScanning: true, + duplicateGroups: [], + duplicateScanProgress: null, + duplicateScanError: null, + duplicateScanWarning: null, + duplicateSelectedIds: new Set(), + }); + const unlisten = await listen("duplicate_scan_progress", (event) => { + set({ duplicateScanProgress: event.payload }); + }); + try { + const result = await invoke("find_duplicates", { folderId: folderId ?? null }); + const warning = result.skipped_files > 0 + ? `${result.skipped_files.toLocaleString()} file${result.skipped_files === 1 ? "" : "s"} could not be read and were skipped.` + : null; + set({ + duplicateGroups: result.groups, + duplicateLastScanned: Math.floor(Date.now() / 1000), + duplicateScanFolderId: folderId, + duplicateScanWarning: warning, + }); + void notifyTaskComplete( + "Duplicate scan complete", + `${result.groups.length === 1 ? "Found 1 duplicate group." : `Found ${result.groups.length.toLocaleString()} duplicate groups.`}${warning ? ` ${warning}` : ""}`, + ); + } catch (e) { + set({ duplicateScanError: String(e) }); + } finally { + unlisten(); + set({ duplicateScanning: false }); + } + }, + + toggleDuplicateSelected: (imageId) => { + set((state) => { + const next = new Set(state.duplicateSelectedIds); + if (next.has(imageId)) next.delete(imageId); + else next.add(imageId); + return { duplicateSelectedIds: next }; + }); + }, + + selectAllDuplicates: (imageIds) => { + set((state) => { + const next = new Set(state.duplicateSelectedIds); + for (const id of imageIds) next.add(id); + return { duplicateSelectedIds: next }; + }); + }, + + selectKeepFirstAllGroups: () => { + const { duplicateGroups } = get(); + const toMark = new Set(); + for (const group of duplicateGroups) { + for (const img of group.images.slice(1)) toMark.add(img.id); + } + set({ duplicateSelectedIds: toMark }); + }, + + clearDuplicateSelection: () => set({ duplicateSelectedIds: new Set() }), + + deleteSelectedDuplicates: async () => { + const { duplicateSelectedIds, duplicateGroups } = get(); + const ids = Array.from(duplicateSelectedIds); + if (ids.length === 0) return 0; + // Backend returns only the IDs that were actually removed from disk. + const succeededIds = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); + const succeededSet = new Set(succeededIds); + // Only remove images confirmed deleted — failed files remain visible so the user can retry. + set((state) => ({ + duplicateSelectedIds: new Set(), + duplicateGroups: state.duplicateGroups + .map((g) => ({ ...g, images: g.images.filter((img) => !succeededSet.has(img.id)) })) + .filter((g) => g.images.length > 1), + })); + // Invalidate the persisted cache for every affected scope: + // - global "all" cache (always, since a folder-scoped deletion still makes the global result stale) + // - each folder that contained a deleted image (so a folder-scoped scan is also evicted) + const affectedFolderIds = new Set( + duplicateGroups + .flatMap((g) => g.images) + .filter((img) => succeededSet.has(img.id)) + .map((img) => img.folder_id), + ); + await invoke("invalidate_duplicate_scan_cache", { folderId: null }); // global + for (const folderId of affectedFolderIds) { + await invoke("invalidate_duplicate_scan_cache", { folderId }); + } + return succeededIds.length; + }, +}); diff --git a/src/store/events.ts b/src/store/events.ts new file mode 100644 index 0000000..b33ae17 --- /dev/null +++ b/src/store/events.ts @@ -0,0 +1,355 @@ +import { listen, UnlistenFn } from "@tauri-apps/api/event"; +import type { StoreApi } from "zustand"; +import { notifyTaskComplete } from "../notifications"; +import { + PAGE_SIZE, + countNewImages, + imagesAffectScope, + isDerivedCollectionTitle, + matchesFilters, + mergeIntoVisibleWindow, + replaceExistingImages, + scopeHasTaggingPending, + taggingProgressAffectsScope, +} from "./helpers"; +import type { GalleryStore } from "./index"; +import type { + CaptionModelProgress, + FfmpegProgressEvent, + IndexProgress, + IndexedImagesBatch, + MediaJobProgressEvent, + TaggerModelProgress, + ThumbnailBatch, +} from "./types"; + +// Per-folder debounce timers for batching notifications. +// Keyed as `${folderId}:embedding` or `${folderId}:tagging`. +const notificationTimers = new Map>(); +const NOTIFICATION_DEBOUNCE_MS = 6000; + +let exploreTagRefreshTimer: ReturnType | null = null; +const EXPLORE_TAG_REFRESH_IDLE_MS = 900; +const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500; + +function scheduleExploreTagRefresh(load: () => void, delayMs: number) { + if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer); + exploreTagRefreshTimer = setTimeout(() => { + exploreTagRefreshTimer = null; + load(); + }, delayMs); +} + +export async function subscribeToProgress( + set: StoreApi["setState"], + get: StoreApi["getState"], +): Promise { + const unlistenProgress = await listen("index-progress", (event) => { + const progress = event.payload; + const previous = get().indexingProgress[progress.folder_id]; + set((state) => ({ + indexingProgress: { + ...state.indexingProgress, + [progress.folder_id]: progress, + }, + })); + + if (progress.done) { + if ( + previous && + !previous.done && + progress.total > 0 && + progress.indexed >= progress.total + ) { + const { notificationsPaused, mutedFolderIds } = get(); + if (!notificationsPaused && !mutedFolderIds.includes(progress.folder_id)) { + const folderName = get().folders.find((folder) => folder.id === progress.folder_id)?.name; + void notifyTaskComplete( + "Folder scan complete", + folderName ? `${folderName} has finished scanning.` : "A folder has finished scanning.", + ); + } + } + void get().loadFolders(); + void get().loadBackgroundJobProgress(); + if (get().activeView !== "explore" && !isDerivedCollectionTitle(get().collectionTitle)) { + void get().loadImages(true); + } + + setTimeout(() => { + set((state) => { + const next = { ...state.indexingProgress }; + delete next[progress.folder_id]; + return { indexingProgress: next }; + }); + }, 2000); + } + }); + + const unlistenMediaJobs = await listen("media-job-progress", (event) => { + const previousProgress = get().mediaJobProgress; + + for (const progress of event.payload.progress) { + const previous = previousProgress[progress.folder_id]; + if (!previous) continue; + + const { notificationsPaused, mutedFolderIds } = get(); + const suppressed = notificationsPaused || mutedFolderIds.includes(progress.folder_id); + const folderName = + get().folders.find((folder) => folder.id === progress.folder_id)?.name ?? "Folder"; + + // Embeddings — debounced so rapid file additions don't fire per-file. + const embeddingKey = `${progress.folder_id}:embedding`; + if (!suppressed) { + if (previous.embedding_pending > 0 && progress.embedding_pending === 0) { + clearTimeout(notificationTimers.get(embeddingKey)); + const failureDetail = + progress.embedding_failed > 0 + ? ` ${progress.embedding_failed.toLocaleString()} failed.` + : ""; + const body = `${folderName} finished generating embeddings.${failureDetail}`; + notificationTimers.set(embeddingKey, setTimeout(() => { + notificationTimers.delete(embeddingKey); + void notifyTaskComplete("Embeddings complete", body); + }, NOTIFICATION_DEBOUNCE_MS)); + } else if (previous.embedding_pending === 0 && progress.embedding_pending > 0) { + // More jobs queued — cancel the pending notification. + clearTimeout(notificationTimers.get(embeddingKey)); + notificationTimers.delete(embeddingKey); + } + } + + // Tagging — same debounce pattern. + const taggingKey = `${progress.folder_id}:tagging`; + if (!suppressed) { + if (previous.tagging_pending > 0 && progress.tagging_pending === 0) { + clearTimeout(notificationTimers.get(taggingKey)); + const failureDetail = + progress.tagging_failed > 0 + ? ` ${progress.tagging_failed.toLocaleString()} failed.` + : ""; + const body = `${folderName} finished generating tags.${failureDetail}`; + notificationTimers.set(taggingKey, setTimeout(() => { + notificationTimers.delete(taggingKey); + void notifyTaskComplete("AI tagging complete", body); + }, NOTIFICATION_DEBOUNCE_MS)); + const state = get(); + if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) { + // New tags landed — refresh the Explore tag cloud after the worker + // settles instead of waiting for the user to revisit Explore. + set({ exploreTagsFolderId: undefined }); + if (state.activeView === "explore" && state.exploreMode === "tags") { + scheduleExploreTagRefresh(() => { + void get().loadExploreTags({ force: true }); + }, EXPLORE_TAG_REFRESH_IDLE_MS); + } + } + } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { + clearTimeout(notificationTimers.get(taggingKey)); + notificationTimers.delete(taggingKey); + } + } + } + + set((state) => { + const next = { ...state.mediaJobProgress }; + for (const progress of event.payload.progress) { + next[progress.folder_id] = progress; + } + return { mediaJobProgress: next }; + }); + }); + + const unlistenCaptionModelProgress = await listen("caption-model-progress", (event) => { + set({ + captionModelProgress: event.payload.done ? null : event.payload, + captionModelPreparing: !event.payload.done, + }); + }); + + const unlistenTaggerModelProgress = await listen("tagger-model-progress", (event) => { + set({ + taggerModelProgress: event.payload.done ? null : event.payload, + taggerModelPreparing: !event.payload.done, + }); + if (event.payload.done) { + void get().loadTaggerModelStatus(); + } + }); + + const unlistenImages = await listen("indexed-images", (event) => { + const batch = event.payload; + + set((state) => { + // Album view holds a fixed membership set; newly-indexed files never + // auto-join it. Guarding on activeView also covers the brief window + // where collectionTitle is null mid sort-change in an album. + if ( + isDerivedCollectionTitle(state.collectionTitle) || + state.activeView === "explore" || + state.activeView === "album" || + state.colorFilter !== null + ) { + return state; + } + + const visibleImages = batch.images.filter((image) => + matchesFilters( + image, + state.selectedFolderId, + state.mediaFilter, + state.favoritesOnly, + state.minimumRating, + state.failedEmbeddingsOnly, + state.failedTaggingOnly, + state.search, + ), + ); + + if (visibleImages.length === 0) { + return state; + } + + const newVisibleCount = countNewImages(state.images, visibleImages); + const visibleWindow = Math.max(state.loadedCount, PAGE_SIZE); + const images = mergeIntoVisibleWindow(state.images, visibleImages, state.sort, visibleWindow); + return { + images, + loadedCount: Math.max(state.loadedCount, Math.min(images.length, visibleWindow)), + totalImages: Math.max(state.totalImages + newVisibleCount, images.length), + }; + }); + }); + + const unlistenThumbnails = await listen("media-updated", (event) => { + const batch = event.payload; + const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); + const state = get(); + if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) { + set({ exploreTagsFolderId: undefined }); + if (state.activeView === "explore" && state.exploreMode === "tags") { + const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId) + ? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS + : EXPLORE_TAG_REFRESH_IDLE_MS; + scheduleExploreTagRefresh(() => { + void get().loadExploreTags({ force: true }); + }, delay); + } + } + + set((state) => { + const selectedImageUpdate = + state.selectedImage && batch.images.some((image) => image.id === state.selectedImage?.id) + ? batch.images.find((image) => image.id === state.selectedImage?.id) ?? state.selectedImage + : state.selectedImage; + + // Album view holds already-loaded images; paint thumbnail/metadata + // fills in place (without re-sorting) so tiles refresh while browsing. + if (state.activeView === "album") { + return { + images: replaceExistingImages(state.images, batch.images), + selectedImage: selectedImageUpdate, + }; + } + + if (isDerivedCollectionTitle(state.collectionTitle) || state.activeView === "explore") { + return { selectedImage: selectedImageUpdate }; + } + + const visibleImages = batch.images.filter((image) => + matchesFilters( + image, + state.selectedFolderId, + state.mediaFilter, + state.favoritesOnly, + state.minimumRating, + state.failedEmbeddingsOnly, + state.failedTaggingOnly, + state.search, + ), + ); + + if (visibleImages.length === 0) { + return { selectedImage: selectedImageUpdate }; + } + + return { + images: replaceExistingImages(state.images, visibleImages), + selectedImage: selectedImageUpdate, + }; + }); + }); + + const unlistenWatcherDeleted = await listen("watcher-deleted", (event) => { + const deletedIds = new Set(event.payload); + set((state) => { + const removed = state.images.filter((img) => deletedIds.has(img.id)).length; + const images = state.images.filter((img) => !deletedIds.has(img.id)); + const selectedImage = + state.selectedImage && deletedIds.has(state.selectedImage.id) + ? null + : state.selectedImage; + return { + images, + totalImages: Math.max(0, state.totalImages - removed), + loadedCount: Math.max(0, state.loadedCount - removed), + selectedImage, + }; + }); + }); + + const unlistenFolderCounts = await listen("folder-counts-changed", () => { + void get().loadFolders(); + }); + + const unlistenFfmpegProgress = await listen("ffmpeg-progress", (event) => { + const payload = event.payload; + switch (payload.phase) { + case "starting": + set({ ffmpegStatus: "starting", ffmpegError: null }); + break; + case "downloading": + set({ + ffmpegStatus: "downloading", + ffmpegProgress: + payload.downloaded_bytes !== null && payload.total_bytes !== null + ? { downloaded_bytes: payload.downloaded_bytes, total_bytes: payload.total_bytes } + : null, + }); + break; + case "unpacking": + set({ ffmpegStatus: "unpacking" }); + break; + case "done": + set({ ffmpegStatus: "installed", ffmpegProgress: null, ffmpegError: null }); + break; + case "error": + set({ ffmpegStatus: "error", ffmpegError: payload.error ?? "Download failed" }); + break; + } + }); + + const unlistenColorBackfill = await listen<{ processed: number; total: number; done: boolean }>( + "color-backfill-progress", + (event) => { + set({ colorBackfill: event.payload.done ? null : event.payload }); + }, + ); + + return () => { + if (exploreTagRefreshTimer) { + clearTimeout(exploreTagRefreshTimer); + exploreTagRefreshTimer = null; + } + unlistenProgress(); + unlistenMediaJobs(); + unlistenCaptionModelProgress(); + unlistenTaggerModelProgress(); + unlistenImages(); + unlistenThumbnails(); + unlistenWatcherDeleted(); + unlistenFolderCounts(); + unlistenFfmpegProgress(); + unlistenColorBackfill(); + }; +} diff --git a/src/store/exploreSlice.ts b/src/store/exploreSlice.ts new file mode 100644 index 0000000..53e0ef2 --- /dev/null +++ b/src/store/exploreSlice.ts @@ -0,0 +1,282 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { isCurrentGalleryRequest, nextGalleryRequestToken, parseSearchValue } from "./helpers"; +import type { GalleryStore } from "./index"; +import type { + ExploreMode, + ExploreTagEntry, + ImageExif, + ImageRecord, + ImageTag, + RelatedTagEntry, + VisualClusterEntry, +} from "./types"; + +let visualClusterRequestToken = 0; +let exploreTagRequestToken = 0; + +export interface ExploreSlice { + exploreMode: ExploreMode; + tagManagerOpen: boolean; + visualClusterEntries: VisualClusterEntry[]; + visualClusterLoading: boolean; + visualClusterFolderId: number | null | undefined; // undefined = never loaded + exploreTagEntries: ExploreTagEntry[]; + exploreTagLoading: boolean; + // Cache-freshness key: the folder the loaded tags belong to. Set to undefined + // by content mutations (tag add/remove/rename/delete) to mark the cache dirty + // and force the next load to refetch. + exploreTagsFolderId: number | null | undefined; + // The folder whose tags are actually on screen. Kept separate from the dirty + // marker above so a same-folder invalidation isn't mistaken for a folder switch + // (which would wipe the visible list and remount manager UI mid-refresh). + exploreTagsShownFolderId: number | null | undefined; + relatedTagsByKey: Record; + + setExploreMode: (mode: ExploreMode) => void; + setTagManagerOpen: (open: boolean) => void; + openTagManager: () => void; + loadVisualClusters: (options?: { force?: boolean }) => Promise; + loadExploreTags: (options?: { force?: boolean }) => Promise; + loadRelatedTags: (tag: string) => Promise; + showVisualCluster: (imageIds: number[]) => Promise; + suggestImageTags: (imageId: number) => Promise; + getImageTags: (imageId: number) => Promise; + addUserTag: (imageId: number, tag: string) => Promise; + removeTag: (tagId: number) => Promise; + getImageExif: (imageId: number) => Promise; + renameTag: (from: string, to: string) => Promise; + deleteTag: (tag: string) => Promise; +} + +export const createExploreSlice: StateCreator = (set, get) => ({ + exploreMode: "visual", + tagManagerOpen: false, + visualClusterEntries: [], + visualClusterLoading: false, + visualClusterFolderId: undefined, + exploreTagEntries: [], + exploreTagLoading: false, + exploreTagsFolderId: undefined, + exploreTagsShownFolderId: undefined, + relatedTagsByKey: {}, + + setExploreMode: (exploreMode) => + // Manage mode only exists for the tag view; drop it when switching to visual + // clusters so re-entering the tag view starts in the normal browse state. + set(exploreMode === "visual" ? { exploreMode, tagManagerOpen: false } : { exploreMode }), + setTagManagerOpen: (tagManagerOpen) => set({ tagManagerOpen }), + // Jump straight to the tag manager from anywhere (e.g. the Settings panel): + // switch to Explore, select the tag view, open manage mode, and close Settings. + openTagManager: () => { + get().setView("explore"); + set({ exploreMode: "tags", tagManagerOpen: true, settingsOpen: false }); + }, + + loadVisualClusters: async (options) => { + const { selectedFolderId, visualClusterFolderId, visualClusterLoading } = get(); + const force = options?.force ?? false; + // Skip if already loaded for this folder and not currently loading + if (!force && !visualClusterLoading && visualClusterFolderId !== undefined && visualClusterFolderId === selectedFolderId) { + return; + } + const requestToken = ++visualClusterRequestToken; + // On a real folder switch, drop the previous folder's clusters so the loading + // panel shows instead of lingering stale results. A same-folder refresh keeps + // them to avoid a flicker when the cache returns instantly. + const isFolderSwitch = visualClusterFolderId !== selectedFolderId; + set({ + visualClusterLoading: true, + visualClusterFolderId: selectedFolderId, + ...(isFolderSwitch ? { visualClusterEntries: [] } : {}), + }); + try { + const entries = await invoke("get_visual_clusters", { + folderId: selectedFolderId, + }); + if (requestToken !== visualClusterRequestToken) return; + set({ visualClusterEntries: entries, visualClusterLoading: false }); + } catch (error) { + if (requestToken !== visualClusterRequestToken) return; + console.error("Failed to load tag cloud:", error); + set({ visualClusterLoading: false }); + } + }, + + loadExploreTags: async (options) => { + const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); + const force = options?.force ?? false; + if (!force && exploreTagLoading) { + return; + } + if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { + return; + } + const requestToken = ++exploreTagRequestToken; + // A real folder switch is decided by what's currently *shown*, not by the + // dirty marker — a same-folder invalidation nulls exploreTagsFolderId but + // leaves exploreTagsShownFolderId pointing at the displayed folder, so the + // visible list (and manager UI state) survives the refresh. On an actual + // switch, drop the previous folder's tags so the loading panel shows. + const { exploreTagsShownFolderId } = get(); + const isFolderSwitch = + exploreTagsShownFolderId !== undefined && exploreTagsShownFolderId !== selectedFolderId; + set({ + exploreTagLoading: true, + exploreTagsFolderId: selectedFolderId, + exploreTagsShownFolderId: selectedFolderId, + ...(isFolderSwitch ? { exploreTagEntries: [], relatedTagsByKey: {} } : {}), + }); + try { + const entries = await invoke("get_explore_tags", { + params: { folder_id: selectedFolderId, limit: 180 }, + }); + if (requestToken !== exploreTagRequestToken) return; + set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} }); + } catch (error) { + if (requestToken !== exploreTagRequestToken) return; + console.error("Failed to load explore tags:", error); + set({ exploreTagLoading: false }); + } + }, + + loadRelatedTags: async (tag) => { + const trimmed = tag.trim(); + if (!trimmed) return []; + + const { selectedFolderId, relatedTagsByKey } = get(); + const key = `${selectedFolderId ?? "all"}:${trimmed}`; + if (relatedTagsByKey[key]) { + return relatedTagsByKey[key]; + } + + const entries = await invoke("get_related_tags", { + params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 }, + }); + set((state) => ({ + relatedTagsByKey: { + ...state.relatedTagsByKey, + [key]: entries, + }, + })); + return entries; + }, + + showVisualCluster: async (imageIds) => { + const requestToken = nextGalleryRequestToken(); + set((state) => ({ + activeView: "gallery", + search: "", + images: [], + totalImages: imageIds.length, + loadedCount: 0, + loadingImages: true, + collectionTitle: "Explore Cluster", + imageLoadError: null, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + gallerySelectedIds: new Set(), + selectedAlbumId: null, + galleryScrollResetKey: state.galleryScrollResetKey + 1, + })); + + try { + const images = await invoke("get_images_by_ids", { + params: { image_ids: imageIds }, + }); + if (!isCurrentGalleryRequest(requestToken)) return; + set({ + images, + totalImages: images.length, + loadedCount: images.length, + loadingImages: false, + imageLoadError: null, + collectionTitle: "Explore Cluster", + }); + } catch (error) { + if (!isCurrentGalleryRequest(requestToken)) return; + set({ + images: [], + totalImages: 0, + loadedCount: 0, + loadingImages: false, + imageLoadError: String(error), + collectionTitle: "Explore Cluster", + }); + } + }, + + suggestImageTags: async (imageId) => { + return invoke("suggest_image_tags", { + params: { image_id: imageId, limit: 2 }, + }); + }, + + getImageTags: async (imageId) => { + return invoke("get_image_tags", { + params: { image_id: imageId }, + }); + }, + + addUserTag: async (imageId, tag) => { + const result = await invoke("add_user_tag", { + params: { image_id: imageId, tag }, + }); + // Invalidate explore tags cache so new tag appears immediately + set({ exploreTagsFolderId: undefined }); + return result; + }, + + removeTag: async (tagId) => { + await invoke("remove_tag", { + params: { tag_id: tagId }, + }); + // Invalidate explore tags cache so removed tag disappears immediately + set({ exploreTagsFolderId: undefined }); + }, + + getImageExif: async (imageId) => { + return invoke("get_image_exif", { params: { image_id: imageId } }); + }, + + renameTag: async (from, to) => { + await invoke("rename_tag", { params: { from, to } }); + // Tag content changed — invalidate the explore-tags and visual-cluster caches. + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. + set({ + exploreTagsFolderId: undefined, + visualClusterFolderId: undefined, + visualClusterEntries: [], + }); + const parsed = parseSearchValue(get().search); + if (parsed.mode === "tag" && parsed.query === from) { + // An active tag-search points at the old name — repoint it so the gallery + // refreshes instead of showing stale results for a tag that no longer exists. + get().setSearch(`/t ${to}`); + } else if (get().activeView === "explore") { + await get().loadExploreTags(); + } + }, + + deleteTag: async (tag) => { + const removed = await invoke("delete_tag", { params: { tag } }); + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. + set({ + exploreTagsFolderId: undefined, + visualClusterFolderId: undefined, + visualClusterEntries: [], + }); + const parsed = parseSearchValue(get().search); + if (parsed.mode === "tag" && parsed.query === tag) { + // The searched tag is gone — reload so the now-empty result is reflected. + void get().loadImages(true); + } else if (get().activeView === "explore") { + await get().loadExploreTags(); + } + return removed; + }, +}); diff --git a/src/store/gallerySlice.ts b/src/store/gallerySlice.ts new file mode 100644 index 0000000..90d713b --- /dev/null +++ b/src/store/gallerySlice.ts @@ -0,0 +1,522 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { + PAGE_SIZE, + TIMELINE_PAGE_SIZE, + isCurrentGalleryRequest, + isDerivedCollectionTitle, + mergeImages, + nextGalleryRequestToken, + parseSearchValue, + replaceExistingImages, + replaceImage, +} from "./helpers"; +import type { GalleryStore } from "./index"; +import type { + ActiveView, + ImageRecord, + MediaFilter, + SimilarImagesPage, + SortOrder, + ZoomPreset, +} from "./types"; + +export interface GallerySlice { + images: ImageRecord[]; + totalImages: number; + loadedCount: number; + loadingImages: boolean; + imageLoadError: string | null; + sort: SortOrder; + mediaFilter: MediaFilter; + favoritesOnly: boolean; + minimumRating: number; + failedEmbeddingsOnly: boolean; + failedTaggingOnly: boolean; + colorFilter: [number, number, number] | null; // [r,g,b] dominant-color filter + colorBackfill: { processed: number; total: number; done: boolean } | null; + zoomPreset: ZoomPreset; + selectedImage: ImageRecord | null; + collectionTitle: string | null; + galleryScrollResetKey: number; + activeView: ActiveView; + gallerySelectedIds: Set; + + loadImages: (reset?: boolean) => Promise; + loadMoreImages: () => Promise; + setSort: (sort: SortOrder) => void; + setMediaFilter: (filter: MediaFilter) => void; + setFavoritesOnly: (favoritesOnly: boolean) => void; + setMinimumRating: (minimumRating: number) => void; + setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; + setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; + setColorFilter: (color: [number, number, number] | null) => void; + showFailedTagging: (folderId: number) => void; + setZoomPreset: (zoomPreset: ZoomPreset) => void; + openImage: (image: ImageRecord) => void; + closeImage: () => void; + setView: (view: ActiveView) => void; + updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; + + // Gallery multi-select (Feature A) + toggleGallerySelected: (imageId: number) => void; + selectAllGallery: () => void; + clearGallerySelection: () => void; + bulkSetFavorite: (favorite: boolean) => Promise; + bulkSetRating: (rating: number) => Promise; + bulkAddTags: (tags: string[]) => Promise; + bulkRemoveTag: (tag: string) => Promise; + bulkDeleteSelected: () => Promise; +} + +const resetCollectionState = { + images: [] as ImageRecord[], + loadedCount: 0, + collectionTitle: null as string | null, + similarSourceImageId: null as number | null, + similarHasMore: false, + imageLoadError: null as string | null, +}; + +export const createGallerySlice: StateCreator = (set, get) => ({ + images: [], + totalImages: 0, + loadedCount: 0, + loadingImages: false, + imageLoadError: null, + sort: "date_desc", + mediaFilter: "all", + favoritesOnly: false, + minimumRating: 0, + failedEmbeddingsOnly: false, + failedTaggingOnly: false, + colorFilter: null, + colorBackfill: null, + zoomPreset: "comfortable", + selectedImage: null, + collectionTitle: null, + galleryScrollResetKey: 0, + activeView: "gallery", + gallerySelectedIds: new Set(), + + loadImages: async (reset = false) => { + const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, colorFilter, activeView } = get(); + const parsedSearch = parseSearchValue(search); + const requestToken = nextGalleryRequestToken(); + // Any fresh collection load invalidates a selection that referenced the + // previous set of visible images. + set({ loadingImages: true, imageLoadError: null, ...(reset ? { gallerySelectedIds: new Set() } : {}) }); + + try { + // Album view loads from the album membership, honoring sort changes from + // the Toolbar while staying within the album (ignores folder/search/filters). + if (activeView === "album") { + const albumId = get().selectedAlbumId; + if (albumId === null) { + set({ loadingImages: false }); + return; + } + const offset = reset ? 0 : loadedCount; + const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { + params: { album_id: albumId, sort, offset, limit: PAGE_SIZE }, + }); + if (!isCurrentGalleryRequest(requestToken)) return; + const albumName = get().albums.find((entry) => entry.id === albumId)?.name ?? "Album"; + set((state) => ({ + images: reset ? result.images : [...state.images, ...result.images], + totalImages: result.total, + loadedCount: reset ? result.images.length : state.loadedCount + result.images.length, + loadingImages: false, + collectionTitle: albumName, + })); + return; + } + + if (parsedSearch.mode === "semantic" && parsedSearch.query) { + const images = await invoke("semantic_search_images", { + params: { + query: parsedSearch.query, + folder_id: selectedFolderId, + media_kind: mediaFilter === "all" ? null : mediaFilter, + favorites_only: favoritesOnly, + rating_min: minimumRating > 0 ? minimumRating : null, + limit: PAGE_SIZE, + }, + }); + + if (!isCurrentGalleryRequest(requestToken)) return; + set({ + images, + totalImages: images.length, + loadedCount: images.length, + loadingImages: false, + collectionTitle: `Semantic search: ${parsedSearch.query}`, + selectedFolderId, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + }); + return; + } + + if (parsedSearch.mode === "tag" && parsedSearch.query) { + const offset = reset ? 0 : loadedCount; + const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("search_images_by_tag", { + params: { + query: parsedSearch.query, + folder_id: selectedFolderId, + media_kind: mediaFilter === "all" ? null : mediaFilter, + favorites_only: favoritesOnly, + rating_min: minimumRating > 0 ? minimumRating : null, + color: colorFilter, + limit: PAGE_SIZE, + offset, + }, + }); + + if (!isCurrentGalleryRequest(requestToken)) return; + if (reset) { + set({ + images: result.images, + totalImages: result.total, + loadedCount: result.images.length, + loadingImages: false, + collectionTitle: `Tag search: ${parsedSearch.query}`, + selectedFolderId, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + }); + } else { + set((state) => ({ + images: [...state.images, ...result.images], + loadedCount: state.loadedCount + result.images.length, + totalImages: result.total, + loadingImages: false, + })); + } + return; + } + + const offset = reset ? 0 : loadedCount; + const result = await invoke<{ + images: ImageRecord[]; + total: number; + offset: number; + limit: number; + }>("get_images", { + params: { + folder_id: selectedFolderId, + search: parsedSearch.query || null, + media_kind: mediaFilter === "all" ? null : mediaFilter, + favorites_only: favoritesOnly, + rating_min: minimumRating > 0 ? minimumRating : null, + embedding_failed_only: failedEmbeddingsOnly, + tagging_failed_only: failedTaggingOnly, + color: colorFilter, + sort, + offset, + limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, + }, + }); + + if (!isCurrentGalleryRequest(requestToken)) return; + set((state) => ({ + images: reset ? result.images : [...state.images, ...result.images], + totalImages: result.total, + loadedCount: reset ? result.images.length : state.loadedCount + result.images.length, + loadingImages: false, + collectionTitle: reset ? null : state.collectionTitle, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + })); + } catch (error) { + if (!isCurrentGalleryRequest(requestToken)) return; + console.error("Failed to load media:", error); + set({ loadingImages: false, imageLoadError: String(error) }); + } + }, + + loadMoreImages: async () => { + const { loadedCount, totalImages, loadingImages, collectionTitle, similarSourceImageId, similarHasMore, similarFolderId, similarCrop } = get(); + if (loadingImages || loadedCount >= totalImages) return; + if (collectionTitle === "Explore Cluster") return; + const { activeView, selectedAlbumId, sort } = get(); + if (activeView === "album" && selectedAlbumId !== null) { + const requestToken = nextGalleryRequestToken(); + set({ loadingImages: true }); + try { + const result = await invoke<{ images: ImageRecord[]; total: number; offset: number; limit: number }>("get_album_images", { + params: { album_id: selectedAlbumId, sort, offset: loadedCount, limit: PAGE_SIZE }, + }); + if (!isCurrentGalleryRequest(requestToken)) return; + set((state) => ({ + images: [...state.images, ...result.images], + loadedCount: state.loadedCount + result.images.length, + totalImages: result.total, + loadingImages: false, + })); + } catch { + if (!isCurrentGalleryRequest(requestToken)) return; + set({ loadingImages: false }); + } + return; + } + const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null; + if (collectionTitle === "Similar Images" && similarSourceImageId !== null) { + if (!similarHasMore) return; + await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId); + return; + } + if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) { + if (!similarHasMore) return; + const requestToken = nextGalleryRequestToken(); + set({ loadingImages: true }); + try { + const result = await invoke("find_similar_by_region", { + params: { + image_id: similarSourceImageId, + crop_x: similarCrop.x, + crop_y: similarCrop.y, + crop_w: similarCrop.w, + crop_h: similarCrop.h, + folder_id: pageAlbumId !== null ? null : similarFolderId, + album_id: pageAlbumId, + offset: loadedCount, + limit: PAGE_SIZE, + }, + }); + if (!isCurrentGalleryRequest(requestToken)) return; + set((state) => ({ + images: [...state.images, ...result.images], + loadedCount: state.loadedCount + result.images.length, + totalImages: result.has_more ? state.loadedCount + result.images.length + 1 : state.loadedCount + result.images.length, + similarHasMore: result.has_more, + loadingImages: false, + })); + } catch { + if (!isCurrentGalleryRequest(requestToken)) return; + set({ loadingImages: false }); + } + return; + } + await get().loadImages(false); + }, + + setSort: (sort) => { + set({ sort, ...resetCollectionState }); + void get().loadImages(true); + }, + + setMediaFilter: (mediaFilter) => { + set({ mediaFilter, ...resetCollectionState }); + void get().loadImages(true); + }, + + setFavoritesOnly: (favoritesOnly) => { + set({ favoritesOnly, ...resetCollectionState }); + void get().loadImages(true); + }, + + setMinimumRating: (minimumRating) => { + set({ minimumRating, ...resetCollectionState }); + void get().loadImages(true); + }, + + setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { + set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, ...resetCollectionState }); + void get().loadImages(true); + }, + + setFailedTaggingOnly: (failedTaggingOnly) => { + set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, ...resetCollectionState }); + void get().loadImages(true); + }, + + setColorFilter: (colorFilter) => { + set({ colorFilter, ...resetCollectionState }); + void get().loadImages(true); + }, + + showFailedTagging: (folderId) => { + set({ + selectedFolderId: folderId, + activeView: "gallery", + search: "", + mediaFilter: "all", + favoritesOnly: false, + minimumRating: 0, + failedEmbeddingsOnly: false, + failedTaggingOnly: true, + images: [], + loadedCount: 0, + collectionTitle: null, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + similarCrop: null, + imageLoadError: null, + }); + void get().loadImages(true); + }, + + setZoomPreset: (zoomPreset) => set({ zoomPreset }), + + openImage: (image) => set({ selectedImage: image }), + closeImage: () => set({ selectedImage: null }), + + setView: (activeView) => { + // Leaving an album view drops the album-origin similar scope. + const similarScopeReset = get().similarScope === "current_album" ? "all_media" : get().similarScope; + if (activeView === "timeline") { + set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarSourceAlbumId: null, similarScope: similarScopeReset, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null }); + void get().loadImages(true); + return; + } + if (activeView === "duplicates") { + const { selectedFolderId, duplicateScanFolderId } = get(); + if (duplicateScanFolderId !== selectedFolderId) { + set({ + activeView, + duplicateGroups: [], + duplicateLastScanned: null, + duplicateScanFolderId: undefined, + duplicateScanWarning: null, + }); + void get().loadDuplicateScanCache(selectedFolderId); + return; + } + } + // Entering Explore normally always starts in browse mode; openTagManager() is + // the only path that re-opens manage mode (it runs this then sets the flag). + set({ + activeView, + similarSourceAlbumId: null, + similarScope: similarScopeReset, + ...(activeView === "explore" ? { tagManagerOpen: false } : {}), + }); + }, + + updateImageDetails: async (imageId, updates) => { + const updatedImage = await invoke("update_image_details", { + params: { + image_id: imageId, + favorite: updates.favorite ?? null, + rating: updates.rating ?? null, + }, + }); + + set((state) => ({ + // Derived collections (similar / region / semantic / tag / album results) + // are ordered by relevance, not `state.sort` — re-sorting them on a + // favorite/rating change would scramble the results. Replace in place + // there; only the real sorted gallery re-sorts. + images: isDerivedCollectionTitle(state.collectionTitle) + ? replaceExistingImages(state.images, [updatedImage]) + : replaceImage(state.images, updatedImage, state.sort), + selectedImage: state.selectedImage?.id === updatedImage.id ? updatedImage : state.selectedImage, + })); + }, + + // ── Gallery multi-select (Feature A) ────────────────────────────────────── + + toggleGallerySelected: (imageId) => { + set((state) => { + const next = new Set(state.gallerySelectedIds); + if (next.has(imageId)) next.delete(imageId); + else next.add(imageId); + return { gallerySelectedIds: next }; + }); + }, + + selectAllGallery: () => { + set((state) => ({ gallerySelectedIds: new Set(state.images.map((image) => image.id)) })); + }, + + clearGallerySelection: () => set({ gallerySelectedIds: new Set() }), + + bulkSetFavorite: async (favorite) => { + const ids = Array.from(get().gallerySelectedIds); + if (ids.length === 0) return; + const updated = await invoke("bulk_update_details", { + params: { image_ids: ids, favorite, rating: null }, + }); + set((state) => { + const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id); + // Derived collections keep their relevance order (replace in place); only + // the real sorted gallery re-sorts. + return { + images: isDerivedCollectionTitle(state.collectionTitle) + ? replaceExistingImages(state.images, updated) + : mergeImages(state.images, updated, state.sort), + selectedImage: match ?? state.selectedImage, + }; + }); + }, + + bulkSetRating: async (rating) => { + const ids = Array.from(get().gallerySelectedIds); + if (ids.length === 0) return; + const updated = await invoke("bulk_update_details", { + params: { image_ids: ids, favorite: null, rating }, + }); + set((state) => { + const match = state.selectedImage && updated.find((image) => image.id === state.selectedImage!.id); + return { + images: isDerivedCollectionTitle(state.collectionTitle) + ? replaceExistingImages(state.images, updated) + : mergeImages(state.images, updated, state.sort), + selectedImage: match ?? state.selectedImage, + }; + }); + }, + + bulkAddTags: async (tags) => { + const ids = Array.from(get().gallerySelectedIds); + const cleaned = tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0); + if (ids.length === 0 || cleaned.length === 0) return; + await invoke("bulk_add_tags", { params: { image_ids: ids, tags: cleaned } }); + // New tags landed — invalidate Explore tag caches. + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + }, + + bulkRemoveTag: async (tag) => { + const ids = Array.from(get().gallerySelectedIds); + if (ids.length === 0 || !tag.trim()) return; + await invoke("bulk_remove_tag", { params: { image_ids: ids, tag: tag.trim() } }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + }, + + bulkDeleteSelected: async () => { + const ids = Array.from(get().gallerySelectedIds); + if (ids.length === 0) return 0; + const affectedFolderIds = new Set( + get().images.filter((image) => get().gallerySelectedIds.has(image.id)).map((image) => image.folder_id), + ); + const succeededIds = await invoke("delete_images_from_disk", { params: { image_ids: ids } }); + const succeededSet = new Set(succeededIds); + set((state) => ({ + // Only remove images confirmed deleted — failed files remain selected for retry. + images: state.images.filter((image) => !succeededSet.has(image.id)), + loadedCount: state.images.filter((image) => !succeededSet.has(image.id)).length, + totalImages: Math.max(0, state.totalImages - succeededIds.length), + gallerySelectedIds: new Set([...state.gallerySelectedIds].filter((id) => !succeededSet.has(id))), + // Deletion changes tag/duplicate/album aggregates. + visualClusterFolderId: undefined, + visualClusterEntries: [], + exploreTagsFolderId: undefined, + })); + // The DB cascade already removed these from album_images; refresh counts/covers. + void get().loadAlbums(); + await invoke("invalidate_duplicate_scan_cache", { folderId: null }); + for (const folderId of affectedFolderIds) { + await invoke("invalidate_duplicate_scan_cache", { folderId }); + } + return succeededIds.length; + }, +}); diff --git a/src/store/helpers.ts b/src/store/helpers.ts new file mode 100644 index 0000000..5c87c5d --- /dev/null +++ b/src/store/helpers.ts @@ -0,0 +1,249 @@ +import type { ImageRecord, MediaFilter, ParsedSearch, SearchCommand, SortOrder, ZoomPreset } from "./types"; + +export const PAGE_SIZE = 200; +// Timeline loads its full filtered set in one indexed taken_at query so the +// scrubber can span the entire library and jump to any month. Rendering is +// virtualized, so the cost is one query + records in memory — fine at this scale. +export const TIMELINE_PAGE_SIZE = 100000; +export const SIMILAR_DISTANCE_THRESHOLD = 0.24; + +export function parseSearchValue(search: string): ParsedSearch { + if (!search.trim()) { + return { mode: "filename", query: "", prefix: null }; + } + + const slashPrefix = search.match(/^\/([a-z])(?:\s|$)/i); + if (slashPrefix) { + const rawPrefix = slashPrefix[1].toLowerCase(); + const query = search.length > 3 ? search.slice(3) : ""; + if (rawPrefix === "s") { + return { mode: "semantic", query, prefix: "/s" }; + } + if (rawPrefix === "t") { + return { mode: "tag", query, prefix: "/t" }; + } + return { mode: "filename", query, prefix: rawPrefix === "f" ? "/f" : null }; + } + + const trimmed = search.trim(); + const match = trimmed.match(/^([a-z]):\s*(.*)$/i); + if (!match) { + return { mode: "filename", query: trimmed, prefix: null }; + } + + const rawPrefix = match[1].toLowerCase(); + const query = match[2].trim(); + if (rawPrefix === "s") { + return { mode: "semantic", query, prefix: "s:" }; + } + if (rawPrefix === "t") { + return { mode: "tag", query, prefix: "t:" }; + } + return { mode: "filename", query, prefix: rawPrefix === "f" ? "f:" : null }; +} + +export function searchModeLabel(mode: SearchCommand): string { + switch (mode) { + case "semantic": + return "Semantic Search"; + case "tag": + return "Tag Search"; + default: + return "Filename Search"; + } +} + +export function tileSizeForZoom(zoomPreset: ZoomPreset): number { + switch (zoomPreset) { + case "compact": + return 160; + case "detail": + return 280; + default: + return 220; + } +} + +export function matchesSearch(image: ImageRecord, search: string): boolean { + if (!search) return true; + return image.filename.toLowerCase().includes(search.toLowerCase()); +} + +export function isDerivedCollectionTitle(collectionTitle: string | null): boolean { + return collectionTitle !== null; +} + +export function matchesFilters( + image: ImageRecord, + selectedFolderId: number | null, + mediaFilter: MediaFilter, + favoritesOnly: boolean, + minimumRating: number, + failedEmbeddingsOnly: boolean, + failedTaggingOnly: boolean, + search: string, +): boolean { + const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; + const matchesMedia = mediaFilter === "all" || image.media_kind === mediaFilter; + const matchesFavorite = !favoritesOnly || image.favorite; + const matchesRating = image.rating >= minimumRating; + const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; + const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null; + return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search); +} + +function compareNullableNumber(a: number | null, b: number | null): number { + return (a ?? 0) - (b ?? 0); +} + +function compareNullableDate(a: string | null, b: string | null): number { + return (a ? Date.parse(a) : 0) - (b ? Date.parse(b) : 0); +} + +function compareImages(a: ImageRecord, b: ImageRecord, sort: SortOrder): number { + switch (sort) { + case "name_asc": + return a.filename.localeCompare(b.filename); + case "name_desc": + return b.filename.localeCompare(a.filename); + case "date_asc": + return compareNullableDate(a.modified_at, b.modified_at); + case "date_desc": + return compareNullableDate(b.modified_at, a.modified_at); + case "size_asc": + return compareNullableNumber(a.file_size, b.file_size); + case "size_desc": + return compareNullableNumber(b.file_size, a.file_size); + case "rating_asc": + return compareNullableNumber(a.rating, b.rating); + case "rating_desc": + return compareNullableNumber(b.rating, a.rating); + case "duration_asc": + return compareNullableNumber(a.duration_ms, b.duration_ms); + case "duration_desc": + return compareNullableNumber(b.duration_ms, a.duration_ms); + case "taken_asc": + return compareNullableDate(a.taken_at ?? a.modified_at, b.taken_at ?? b.modified_at); + case "taken_desc": + return compareNullableDate(b.taken_at ?? b.modified_at, a.taken_at ?? a.modified_at); + default: + return compareNullableDate(b.modified_at, a.modified_at); + } +} + +export function mergeImages(currentImages: ImageRecord[], newImages: ImageRecord[], sort: SortOrder): ImageRecord[] { + const merged = new Map(); + + for (const image of currentImages) { + merged.set(image.path, image); + } + + for (const image of newImages) { + merged.set(image.path, image); + } + + return Array.from(merged.values()).sort((a, b) => compareImages(a, b, sort)); +} + +export function mergeIntoVisibleWindow( + currentImages: ImageRecord[], + newImages: ImageRecord[], + sort: SortOrder, + windowSize: number, +): ImageRecord[] { + const merged = mergeImages(currentImages, newImages, sort); + return merged.slice(0, Math.max(windowSize, 0)); +} + +export function countNewImages(currentImages: ImageRecord[], newImages: ImageRecord[]): number { + const existingPaths = new Set(currentImages.map((image) => image.path)); + let count = 0; + + for (const image of newImages) { + if (!existingPaths.has(image.path)) { + existingPaths.add(image.path); + count += 1; + } + } + + return count; +} + +export function replaceImage(images: ImageRecord[], updatedImage: ImageRecord, sort: SortOrder): ImageRecord[] { + return mergeImages(images, [updatedImage], sort); +} + +export function replaceExistingImages( + currentImages: ImageRecord[], + updatedImages: ImageRecord[], +): ImageRecord[] { + // Replace matched records in place WITHOUT re-sorting. `media-updated` carries + // thumbnail/metadata fills that don't move an item in the list (Timeline + // re-buckets by taken_at separately), and it fires constantly while the + // background workers run. Re-sorting here meant an O(n log n) pass on every + // batch — fine for the ~200-item gallery window, but a UI-freezing churn in + // Timeline view where `images` can hold the entire library (TIMELINE_PAGE_SIZE). + // Returning the same array reference when nothing matched also avoids a wasted + // re-render. Relative order for just-updated items is corrected on next load. + const updatesByPath = new Map(updatedImages.map((image) => [image.path, image])); + let changed = false; + const nextImages = currentImages.map((image) => { + const update = updatesByPath.get(image.path); + if (!update) return image; + changed = true; + return update; + }); + return changed ? nextImages : currentImages; +} + +export function initialAiCaptionsEnabled(key: string): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(key) === "true"; +} + +export function initialBoolSetting(key: string, fallback: boolean): boolean { + if (typeof window === "undefined") return fallback; + const stored = window.localStorage.getItem(key); + return stored === null ? fallback : stored === "true"; +} + +export function initialNumberSetting(key: string, fallback: number, min: number, max: number): number { + if (typeof window === "undefined") return fallback; + const raw = window.localStorage.getItem(key); + if (raw === null) return fallback; + const stored = Number(raw); + if (!Number.isFinite(stored)) return fallback; + return Math.min(max, Math.max(min, stored)); +} + +// Single token shared by all gallery-producing requests (folder loads, searches, +// similarity, region search, album views, explore clusters). Any new request +// increments it so a stale response from a previous collection type cannot +// overwrite newer results. +let galleryRequestToken = 0; + +export function nextGalleryRequestToken(): number { + return ++galleryRequestToken; +} + +export function isCurrentGalleryRequest(token: number): boolean { + return token === galleryRequestToken; +} + +export function scopeHasTaggingPending( + progressByFolder: Record, + folderId: number | null, +): boolean { + if (folderId === null) { + return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0); + } + return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0; +} + +export function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean { + return scopeFolderId === null || scopeFolderId === progressFolderId; +} + +export function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean { + return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId); +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..b092177 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,49 @@ +import { create } from "zustand"; +import { appDataDir, join } from "@tauri-apps/api/path"; +import { UnlistenFn } from "@tauri-apps/api/event"; +import { createAlbumSlice, AlbumSlice } from "./albumSlice"; +import { createAppSlice, AppSlice } from "./appSlice"; +import { createCaptionSlice, CaptionSlice } from "./captionSlice"; +import { createDuplicateSlice, DuplicateSlice } from "./duplicateSlice"; +import { createExploreSlice, ExploreSlice } from "./exploreSlice"; +import { createGallerySlice, GallerySlice } from "./gallerySlice"; +import { createLibrarySlice, LibrarySlice } from "./librarySlice"; +import { createSearchSlice, SearchSlice } from "./searchSlice"; +import { createSettingsSlice, SettingsSlice } from "./settingsSlice"; +import { createTaggerSlice, TaggerSlice } from "./taggerSlice"; +import { subscribeToProgress } from "./events"; + +export * from "./types"; +export { parseSearchValue, searchModeLabel, tileSizeForZoom } from "./helpers"; + +export type GalleryStore = LibrarySlice & + GallerySlice & + SearchSlice & + ExploreSlice & + AlbumSlice & + DuplicateSlice & + TaggerSlice & + CaptionSlice & + SettingsSlice & + AppSlice & { + subscribeToProgress: () => Promise; + }; + +export const useGalleryStore = create()((set, get, ...rest) => ({ + ...createLibrarySlice(set, get, ...rest), + ...createGallerySlice(set, get, ...rest), + ...createSearchSlice(set, get, ...rest), + ...createExploreSlice(set, get, ...rest), + ...createAlbumSlice(set, get, ...rest), + ...createDuplicateSlice(set, get, ...rest), + ...createTaggerSlice(set, get, ...rest), + ...createCaptionSlice(set, get, ...rest), + ...createSettingsSlice(set, get, ...rest), + ...createAppSlice(set, get, ...rest), + + subscribeToProgress: () => subscribeToProgress(set, get), +})); + +appDataDir().then(async (dir) => { + useGalleryStore.getState().setCacheDir(await join(dir, "thumbnails")); +}); diff --git a/src/store/librarySlice.ts b/src/store/librarySlice.ts new file mode 100644 index 0000000..f930259 --- /dev/null +++ b/src/store/librarySlice.ts @@ -0,0 +1,173 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import type { GalleryStore } from "./index"; +import type { Folder, FolderAddResult, FolderJobProgress, IndexProgress } from "./types"; + +export interface LibrarySlice { + folders: Folder[]; + selectedFolderId: number | null; + indexingProgress: Record; + mediaJobProgress: Record; + + loadFolders: () => Promise; + loadBackgroundJobProgress: () => Promise; + addFolder: (path: string) => Promise; + addFolders: (paths: string[]) => Promise; + listDirectories: (path: string | null) => Promise; + removeFolder: (folderId: number) => Promise; + reindexFolder: (folderId: number) => Promise; + renameFolder: (folderId: number, newName: string) => Promise; + updateFolderPath: (folderId: number, newPath: string) => Promise; + reorderFolders: (folderIds: number[]) => Promise; + selectFolder: (folderId: number | null) => void; + setViewFolderScope: (folderId: number | null) => void; + retryFailedEmbeddings: (folderId: number) => Promise; +} + +export const createLibrarySlice: StateCreator = (set, get) => ({ + folders: [], + selectedFolderId: null, + indexingProgress: {}, + mediaJobProgress: {}, + + loadFolders: async () => { + const folders = await invoke("get_folders"); + set((state) => { + const folderIds = new Set(folders.map((folder) => folder.id)); + const nextSelected = state.taggingQueueFolderIds.filter((folderId) => folderIds.has(folderId)); + return { + folders, + taggingQueueFolderIds: nextSelected, + }; + }); + }, + + loadBackgroundJobProgress: async () => { + const progress = await invoke("get_background_job_progress"); + set(() => ({ + mediaJobProgress: Object.fromEntries(progress.map((entry) => [entry.folder_id, entry])), + })); + }, + + addFolder: async (path) => { + const { loadFolders, loadBackgroundJobProgress } = get(); + await invoke("add_folder", { path }); + await loadFolders(); + await loadBackgroundJobProgress(); + }, + + addFolders: async (paths) => { + const { loadFolders, loadBackgroundJobProgress } = get(); + const results = await invoke("add_folders", { paths }); + await loadFolders(); + await loadBackgroundJobProgress(); + return results; + }, + + listDirectories: (path) => invoke("list_directories", { path }), + + removeFolder: async (folderId) => { + const { selectedFolderId, loadFolders, loadImages, loadBackgroundJobProgress } = get(); + // Optimistically drop it from the sidebar for instant feedback (the backend + // delete of its images/thumbnails can take a moment), clearing the active + // selection if it was this folder. + set((state) => { + const folders = state.folders.filter((folder) => folder.id !== folderId); + return selectedFolderId === folderId ? { folders, selectedFolderId: null } : { folders }; + }); + try { + await invoke("remove_folder", { folderId }); + } catch (error) { + // Removal failed — resync the authoritative list and surface the error. + await loadFolders(); + throw error; + } + await loadFolders(); + await loadBackgroundJobProgress(); + // Invalidate tag cloud and explore-tags cache since library content changed. + set({ visualClusterFolderId: undefined, visualClusterEntries: [], exploreTagsFolderId: undefined }); + // Always refresh the gallery: the removed folder's images may be on screen + // (e.g. in All Media), not only when that folder was the active selection. + await loadImages(true); + }, + + reindexFolder: async (folderId) => { + const { loadFolders, loadBackgroundJobProgress } = get(); + await invoke("reindex_folder", { folderId }); + await loadFolders(); + // Invalidate tag cloud cache since embeddings will be regenerated + set({ visualClusterFolderId: undefined, visualClusterEntries: [] }); + await loadBackgroundJobProgress(); + }, + + renameFolder: async (folderId, newName) => { + await invoke("rename_folder", { folderId, newName }); + await get().loadFolders(); + }, + + updateFolderPath: async (folderId, newPath) => { + const { loadFolders, loadBackgroundJobProgress } = get(); + await invoke("update_folder_path", { folderId, newPath }); + await loadFolders(); + await loadBackgroundJobProgress(); + }, + + reorderFolders: async (folderIds) => { + const previous = get().folders; + const byId = new Map(previous.map((folder) => [folder.id, folder])); + const folders = folderIds + .map((id, index) => { + const folder = byId.get(id); + return folder ? { ...folder, sort_order: index + 1 } : null; + }) + .filter((folder): folder is Folder => folder !== null); + set({ folders }); + try { + await invoke("reorder_folders", { params: { folder_ids: folderIds } }); + } catch (error) { + set({ folders: previous }); + throw error; + } + }, + + selectFolder: (folderId) => { + // Leaving any album: drop the album-origin scope so the Folder/All pills + // highlight correctly again. + const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope; + set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); + 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 ExploreView's useEffect on selectedFolderId. + if (activeView === "explore") return; + + void get().loadImages(true); + }, + + retryFailedEmbeddings: async (folderId) => { + await invoke("retry_failed_embeddings", { params: { folder_id: folderId } }); + await get().loadBackgroundJobProgress(); + }, +}); diff --git a/src/store/searchSlice.ts b/src/store/searchSlice.ts new file mode 100644 index 0000000..3a533c8 --- /dev/null +++ b/src/store/searchSlice.ts @@ -0,0 +1,258 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { PAGE_SIZE, SIMILAR_DISTANCE_THRESHOLD, isCurrentGalleryRequest, nextGalleryRequestToken } from "./helpers"; +import type { GalleryStore } from "./index"; +import type { SearchMode, SimilarImagesPage, SimilarScope } from "./types"; + +export interface SearchSlice { + search: string; + searchMode: SearchMode; + similarSourceImageId: number | null; + similarSourceFolderId: number | null; + similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album") + similarHasMore: boolean; + similarScope: SimilarScope; + similarFolderId: number | null; + similarCrop: { x: number; y: number; w: number; h: number } | null; + + setSearch: (search: string) => void; + clearSearch: () => void; + resetSearch: () => void; + setSearchMode: (mode: SearchMode) => void; + searchForTag: (tag: string) => void; + loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; + loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise; + // Entry points that decide scope (album when launched from an album, else folder/all per similarScope). + findSimilar: (imageId: number, sourceFolderId: number | null) => Promise; + findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise; + setSimilarScope: (scope: SimilarScope) => void; +} + +export const createSearchSlice: StateCreator = (set, get) => ({ + search: "", + searchMode: "filename", + similarSourceImageId: null, + similarSourceFolderId: null, + similarSourceAlbumId: null, + similarHasMore: false, + similarScope: "all_media", + similarFolderId: null, + similarCrop: null, + + setSearch: (search) => { + set({ search, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + clearSearch: () => { + set({ search: "", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + resetSearch: () => { + set({ search: "", searchMode: "filename", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + setSearchMode: (searchMode) => { + set({ searchMode, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + searchForTag: (tag) => { + set({ activeView: "gallery", search: `/t ${tag}`, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, similarFolderId: null, imageLoadError: null }); + void get().loadImages(true); + }, + + loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null, albumId = null) => { + const requestToken = nextGalleryRequestToken(); + const offset = reset ? 0 : get().loadedCount; + const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder"; + // Album scope drives results off album membership, so the folder query is null. + const queryFolderId = albumId !== null ? null : folderId ?? null; + set((state) => ({ + images: reset ? [] : state.images, + loadedCount: reset ? 0 : state.loadedCount, + loadingImages: true, + collectionTitle: "Similar Images", + imageLoadError: null, + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarFolderId: queryFolderId, + similarScope, + // Force the gallery grid so results (and the bulk bar) render regardless + // of which view the search was launched from. + activeView: "gallery", + gallerySelectedIds: reset ? new Set() : state.gallerySelectedIds, + selectedAlbumId: null, + galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey, + })); + + try { + const result = await invoke("find_similar_images", { + params: { + image_id: imageId, + folder_id: queryFolderId, + album_id: albumId, + offset, + limit: PAGE_SIZE, + threshold: SIMILAR_DISTANCE_THRESHOLD, + }, + }); + + if (!isCurrentGalleryRequest(requestToken)) return; + + set((state) => { + const nextImages = reset ? result.images : [...state.images, ...result.images]; + const nextLoadedCount = nextImages.length; + return { + images: nextImages, + totalImages: result.has_more ? nextLoadedCount + 1 : nextLoadedCount, + loadedCount: nextLoadedCount, + loadingImages: false, + imageLoadError: null, + collectionTitle: "Similar Images", + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarHasMore: result.has_more, + similarFolderId: queryFolderId, + similarScope, + selectedImage: reset ? null : state.selectedImage, + }; + }); + } catch (error) { + if (!isCurrentGalleryRequest(requestToken)) return; + console.error("Failed to load similar images:", error); + set({ + images: [], + totalImages: 0, + loadedCount: 0, + loadingImages: false, + imageLoadError: String(error), + collectionTitle: "Similar Images", + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarHasMore: false, + similarFolderId: queryFolderId, + similarScope, + selectedImage: null, + }); + } + }, + + loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null, albumId = null) => { + const requestToken = nextGalleryRequestToken(); + const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder"; + const queryFolderId = albumId !== null ? null : folderId ?? null; + set((state) => ({ + images: [], + loadedCount: 0, + loadingImages: true, + collectionTitle: "Region Search Results", + imageLoadError: null, + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarFolderId: queryFolderId, + similarCrop: crop, + similarScope, + // Force the gallery grid so results (and the bulk bar) render regardless + // of which view the search was launched from. + activeView: "gallery", + gallerySelectedIds: new Set(), + selectedAlbumId: null, + galleryScrollResetKey: state.galleryScrollResetKey + 1, + selectedImage: null, + })); + + try { + const result = await invoke("find_similar_by_region", { + params: { + image_id: imageId, + crop_x: crop.x, + crop_y: crop.y, + crop_w: crop.w, + crop_h: crop.h, + folder_id: queryFolderId, + album_id: albumId, + offset: 0, + limit: PAGE_SIZE, + }, + }); + + if (!isCurrentGalleryRequest(requestToken)) return; + + set({ + images: result.images, + totalImages: result.has_more ? result.images.length + 1 : result.images.length, + loadedCount: result.images.length, + loadingImages: false, + imageLoadError: null, + collectionTitle: "Region Search Results", + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarHasMore: result.has_more, + similarFolderId: queryFolderId, + similarCrop: crop, + similarScope, + }); + } catch (error) { + if (!isCurrentGalleryRequest(requestToken)) return; + console.error("Failed to load region search results:", error); + set({ + images: [], + totalImages: 0, + loadedCount: 0, + loadingImages: false, + imageLoadError: String(error), + collectionTitle: "Region Search Results", + similarSourceImageId: imageId, + similarSourceFolderId: sourceFolderId, + similarHasMore: false, + similarFolderId: queryFolderId, + similarCrop: crop, + similarScope, + selectedImage: null, + }); + } + }, + + // Decide the scope at launch: album when triggered from an album, else the + // current folder/all preference. Sets similarSourceAlbumId so the "Similar: + // Album" pill and scope toggle work afterward. + findSimilar: (imageId, sourceFolderId) => { + const { activeView, selectedAlbumId, similarScope } = get(); + const albumOrigin = activeView === "album" ? selectedAlbumId : null; + set({ similarSourceAlbumId: albumOrigin }); + // Respect the chosen scope; album is the default in an album view but the + // user can override to Folder/All before searching. + if (similarScope === "current_album" && albumOrigin !== null) { + return get().loadSimilarImages(imageId, null, true, sourceFolderId, albumOrigin); + } + const folderId = similarScope === "current_folder" ? sourceFolderId : null; + return get().loadSimilarImages(imageId, folderId, true, sourceFolderId, null); + }, + + findSimilarByRegion: (imageId, crop, sourceFolderId) => { + const { activeView, selectedAlbumId, similarScope } = get(); + const albumOrigin = activeView === "album" ? selectedAlbumId : null; + set({ similarSourceAlbumId: albumOrigin }); + if (similarScope === "current_album" && albumOrigin !== null) { + return get().loadSimilarByRegion(imageId, crop, null, sourceFolderId, albumOrigin); + } + const folderId = similarScope === "current_folder" ? sourceFolderId : null; + return get().loadSimilarByRegion(imageId, crop, folderId, sourceFolderId, null); + }, + + setSimilarScope: (similarScope) => { + set({ similarScope }); + const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get(); + if (similarSourceImageId === null) return; + const albumId = similarScope === "current_album" ? similarSourceAlbumId : null; + const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null; + if (collectionTitle === "Region Search Results" && similarCrop !== null) { + void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId); + } else { + void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId, albumId); + } + }, +}); diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts new file mode 100644 index 0000000..86435bb --- /dev/null +++ b/src/store/settingsSlice.ts @@ -0,0 +1,172 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import { initialBoolSetting, initialNumberSetting } from "./helpers"; +import type { GalleryStore } from "./index"; +import type { + AppTheme, + CleanupOrphanedThumbnailsResult, + DatabaseInfo, + OrphanedThumbnailsInfo, + SlideshowOrder, + SlideshowTransition, + VacuumResult, +} from "./types"; + +const THEME_KEY = "phokus-theme"; +const LIGHTBOX_AUTOPLAY_KEY = "phokus.lightboxAutoplay"; +const LIGHTBOX_AUTO_MUTE_KEY = "phokus.lightboxAutoMute"; +const SLIDESHOW_INTERVAL_KEY = "phokus.slideshowIntervalSeconds"; +const SLIDESHOW_ORDER_KEY = "phokus.slideshowOrder"; +const SLIDESHOW_TRANSITION_KEY = "phokus.slideshowTransition"; + +function initialSlideshowOrder(): SlideshowOrder { + if (typeof window === "undefined") return "sequential"; + const stored = window.localStorage.getItem(SLIDESHOW_ORDER_KEY); + return stored === "random" ? "random" : "sequential"; +} + +function initialSlideshowTransition(): SlideshowTransition { + if (typeof window === "undefined") return "soft-fade"; + const stored = window.localStorage.getItem(SLIDESHOW_TRANSITION_KEY); + return stored === "gentle-motion" ? "gentle-motion" : "soft-fade"; +} + +function initialTheme(): AppTheme { + if (typeof window === "undefined") return "phokus"; + const saved = window.localStorage.getItem(THEME_KEY); + const theme: AppTheme = + saved === "subtle-light" || saved === "conventional-dark" ? saved : "phokus"; + document.documentElement.dataset.theme = theme; + return theme; +} + +export interface SettingsSlice { + cacheDir: string; + settingsOpen: boolean; + folderPickerOpen: boolean; + mutedFolderIds: number[]; + notificationsPaused: boolean; + theme: AppTheme; + lightboxAutoplay: boolean; + lightboxAutoMute: boolean; + slideshowIntervalSeconds: number; + slideshowOrder: SlideshowOrder; + slideshowTransition: SlideshowTransition; + + setCacheDir: (dir: string) => void; + setSettingsOpen: (open: boolean) => void; + setFolderPickerOpen: (open: boolean) => void; + loadMutedFolderIds: () => Promise; + toggleMutedFolder: (folderId: number) => void; + loadNotificationsPaused: () => Promise; + setNotificationsPaused: (paused: boolean) => void; + setTheme: (theme: AppTheme) => void; + setLightboxAutoplay: (enabled: boolean) => void; + setLightboxAutoMute: (enabled: boolean) => void; + setSlideshowIntervalSeconds: (seconds: number) => void; + setSlideshowOrder: (order: SlideshowOrder) => void; + setSlideshowTransition: (transition: SlideshowTransition) => void; + openAppDataFolder: () => Promise; + getDatabaseInfo: () => Promise; + vacuumDatabase: () => Promise; + rebuildSemanticIndex: () => Promise; + getOrphanedThumbnailsInfo: () => Promise; + cleanupOrphanedThumbnails: () => Promise; +} + +export const createSettingsSlice: StateCreator = (set) => ({ + cacheDir: "", + settingsOpen: false, + folderPickerOpen: false, + mutedFolderIds: [], + notificationsPaused: false, + theme: initialTheme(), + lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), + lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), + slideshowIntervalSeconds: initialNumberSetting(SLIDESHOW_INTERVAL_KEY, 6, 3, 60), + slideshowOrder: initialSlideshowOrder(), + slideshowTransition: initialSlideshowTransition(), + + setCacheDir: (cacheDir) => set({ cacheDir }), + setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + setFolderPickerOpen: (folderPickerOpen) => set({ folderPickerOpen }), + + loadMutedFolderIds: async () => { + try { + const folderIds = await invoke("get_muted_folder_ids"); + set({ mutedFolderIds: folderIds }); + } catch { + // fall back to in-memory default + } + }, + + toggleMutedFolder: (folderId) => { + set((state) => { + const next = state.mutedFolderIds.includes(folderId) + ? state.mutedFolderIds.filter((id) => id !== folderId) + : [...state.mutedFolderIds, folderId]; + void invoke("set_muted_folder_ids", { folder_ids: next }).catch(() => {}); + return { mutedFolderIds: next }; + }); + }, + + loadNotificationsPaused: async () => { + try { + const paused = await invoke("get_notifications_paused"); + set({ notificationsPaused: paused }); + } catch { + // fall back to in-memory default + } + }, + + setNotificationsPaused: (paused) => { + set({ notificationsPaused: paused }); + void invoke("set_notifications_paused", { paused }).catch(() => {}); + }, + + setTheme: (theme) => { + window.localStorage.setItem(THEME_KEY, theme); + document.documentElement.dataset.theme = theme; + set({ theme }); + }, + + setLightboxAutoplay: (enabled) => { + window.localStorage.setItem(LIGHTBOX_AUTOPLAY_KEY, String(enabled)); + set({ lightboxAutoplay: enabled }); + }, + + setLightboxAutoMute: (enabled) => { + window.localStorage.setItem(LIGHTBOX_AUTO_MUTE_KEY, String(enabled)); + set({ lightboxAutoMute: enabled }); + }, + + setSlideshowIntervalSeconds: (seconds) => { + const next = Math.min(60, Math.max(3, Math.round(seconds))); + window.localStorage.setItem(SLIDESHOW_INTERVAL_KEY, String(next)); + set({ slideshowIntervalSeconds: next }); + }, + + setSlideshowOrder: (order) => { + window.localStorage.setItem(SLIDESHOW_ORDER_KEY, order); + set({ slideshowOrder: order }); + }, + + setSlideshowTransition: (transition) => { + window.localStorage.setItem(SLIDESHOW_TRANSITION_KEY, transition); + set({ slideshowTransition: transition }); + }, + + openAppDataFolder: async () => { + await invoke("open_app_data_folder"); + }, + + getDatabaseInfo: () => invoke("get_database_info"), + + vacuumDatabase: () => invoke("vacuum_database"), + + rebuildSemanticIndex: () => invoke("rebuild_semantic_index"), + + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), + + cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), +}); diff --git a/src/store/taggerSlice.ts b/src/store/taggerSlice.ts new file mode 100644 index 0000000..1bb6b3d --- /dev/null +++ b/src/store/taggerSlice.ts @@ -0,0 +1,283 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateCreator } from "zustand"; +import type { GalleryStore } from "./index"; +import type { + TaggerAcceleration, + TaggerModel, + TaggerModelProgress, + TaggerModelStatus, + TaggerRuntimeProbe, + TaggingQueueScope, +} from "./types"; + +export interface TaggerSlice { + taggerModelStatus: TaggerModelStatus | null; + taggerModelPreparing: boolean; + taggerModelError: string | null; + taggerModelProgress: TaggerModelProgress | null; + taggerModel: TaggerModel; + taggerAcceleration: TaggerAcceleration; + taggerThreshold: number; + taggerBatchSize: number; + taggerRuntimeProbe: TaggerRuntimeProbe | null; + taggerRuntimeChecking: boolean; + taggingQueueScope: TaggingQueueScope; + taggingQueueFolderIds: number[]; + + loadTaggerModelStatus: () => Promise; + prepareTaggerModel: () => Promise; + deleteTaggerModel: () => Promise; + loadTaggerAcceleration: () => Promise; + setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise; + loadTaggerModel: () => Promise; + setTaggerModel: (model: TaggerModel) => Promise; + loadTaggerThreshold: () => Promise; + setTaggerThreshold: (threshold: number, model?: TaggerModel) => Promise; + loadTaggerBatchSize: () => Promise; + setTaggerBatchSize: (batchSize: number) => Promise; + probeTaggerRuntime: () => Promise; + queueTaggingJobs: (folderId?: number | null) => Promise; + queueTaggingJobsForFolders: (folderIds: number[]) => Promise; + queueTaggingForImage: (imageId: number) => Promise; + clearTaggingJobs: (folderId?: number | null) => Promise; + clearTaggingJobsForFolders: (folderIds: number[]) => Promise; + resetAiTags: (folderId?: number | null) => Promise; + resetAiTagsForFolders: (folderIds: number[]) => Promise; + loadTaggingQueueScope: () => Promise; + setTaggingQueueScope: (scope: TaggingQueueScope) => void; + loadTaggingQueueFolderIds: () => Promise; + toggleTaggingQueueFolder: (folderId: number) => void; + setTaggingQueueFolderIds: (folderIds: number[]) => void; +} + +export const createTaggerSlice: StateCreator = (set, get) => ({ + taggerModelStatus: null, + taggerModelPreparing: false, + taggerModelError: null, + taggerModelProgress: null, + taggerModel: "wd", + taggerAcceleration: "auto", + taggerThreshold: 0.35, + taggerBatchSize: 8, + taggerRuntimeProbe: null, + taggerRuntimeChecking: false, + taggingQueueScope: "all", + taggingQueueFolderIds: [], + + loadTaggerModelStatus: async () => { + try { + const taggerModelStatus = await invoke("get_tagger_model_status"); + set({ taggerModelStatus, taggerModelError: null }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + loadTaggerAcceleration: async () => { + try { + const taggerAcceleration = await invoke("get_tagger_acceleration"); + set({ taggerAcceleration }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerAcceleration: async (acceleration) => { + const taggerAcceleration = await invoke("set_tagger_acceleration", { + params: { acceleration }, + }); + set({ taggerAcceleration, taggerRuntimeProbe: null }); + }, + + loadTaggerModel: async () => { + try { + const taggerModel = await invoke("get_tagger_model"); + // Never clobber the valid default with a missing/blank backend response. + if (taggerModel) set({ taggerModel }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerModel: async (model) => { + const taggerModel = await invoke("set_tagger_model", { + params: { model }, + }); + // Switching models changes both readiness and the active threshold setting, + // so refresh them together for the selected model. + try { + const [taggerModelStatus, taggerThreshold] = await Promise.all([ + invoke("get_tagger_model_status"), + invoke("get_tagger_threshold"), + ]); + set({ taggerModel, taggerModelStatus, taggerThreshold, taggerModelError: null, taggerRuntimeProbe: null }); + } catch (error) { + set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) }); + } + }, + + loadTaggerThreshold: async () => { + try { + const taggerThreshold = await invoke("get_tagger_threshold"); + set({ taggerThreshold }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerThreshold: async (threshold, model) => { + const taggerThreshold = await invoke("set_tagger_threshold", { + params: { threshold, model }, + }); + if (!model || get().taggerModel === model) { + set({ taggerThreshold }); + } + }, + + loadTaggerBatchSize: async () => { + try { + const taggerBatchSize = await invoke("get_tagger_batch_size"); + set({ taggerBatchSize }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerBatchSize: async (batchSize) => { + const taggerBatchSize = await invoke("set_tagger_batch_size", { + params: { batch_size: batchSize }, + }); + set({ taggerBatchSize }); + }, + + prepareTaggerModel: async () => { + set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null }); + try { + const taggerModelStatus = await invoke("prepare_tagger_model"); + set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null }); + } catch (error) { + set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null }); + } + }, + + deleteTaggerModel: async () => { + set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null }); + try { + const taggerModelStatus = await invoke("delete_tagger_model"); + set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, taggerRuntimeProbe: null }); + } catch (error) { + set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null }); + } + }, + + probeTaggerRuntime: async () => { + set({ taggerRuntimeChecking: true, taggerModelError: null }); + try { + const taggerRuntimeProbe = await invoke("probe_tagger_runtime"); + set({ taggerRuntimeProbe, taggerRuntimeChecking: false, taggerModelError: null }); + } catch (error) { + set({ taggerRuntimeChecking: false, taggerModelError: String(error), taggerRuntimeProbe: null }); + } + }, + + queueTaggingJobs: async (folderId = get().selectedFolderId) => { + const queued = await invoke("queue_tagging_jobs", { + params: { folder_id: folderId ?? null, image_id: null }, + }); + await get().loadBackgroundJobProgress(); + return queued; + }, + + queueTaggingJobsForFolders: async (folderIds) => { + const queued = await invoke("queue_tagging_jobs", { + params: { folder_id: null, folder_ids: folderIds, image_id: null }, + }); + await get().loadBackgroundJobProgress(); + return queued; + }, + + queueTaggingForImage: async (imageId) => { + const queued = await invoke("queue_tagging_jobs", { + params: { folder_id: null, image_id: imageId }, + }); + await get().loadBackgroundJobProgress(); + return queued; + }, + + clearTaggingJobs: async (folderId = get().selectedFolderId) => { + const cleared = await invoke("clear_tagging_jobs", { + params: { folder_id: folderId ?? null }, + }); + await get().loadBackgroundJobProgress(); + return cleared; + }, + + clearTaggingJobsForFolders: async (folderIds) => { + const cleared = await invoke("clear_tagging_jobs", { + params: { folder_id: null, folder_ids: folderIds }, + }); + await get().loadBackgroundJobProgress(); + return cleared; + }, + + resetAiTags: async (folderId = get().selectedFolderId) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: folderId ?? null, folder_ids: null }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + + resetAiTagsForFolders: async (folderIds) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: null, folder_ids: folderIds }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + + loadTaggingQueueScope: async () => { + try { + const scope = await invoke("get_tagging_queue_scope"); + set({ taggingQueueScope: scope }); + } catch { + // silently fall back to in-memory default + } + }, + + setTaggingQueueScope: (taggingQueueScope) => { + set((state) => ({ + taggingQueueScope, + taggingQueueFolderIds: state.taggingQueueFolderIds, + })); + void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); + }, + + loadTaggingQueueFolderIds: async () => { + try { + const folderIds = await invoke("get_tagging_queue_folder_ids"); + set({ taggingQueueFolderIds: folderIds }); + } catch { + // silently fall back to in-memory default + } + }, + + toggleTaggingQueueFolder: (folderId) => { + set((state) => { + const next = state.taggingQueueFolderIds.includes(folderId) + ? state.taggingQueueFolderIds.filter((id) => id !== folderId) + : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); + void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {}); + return { taggingQueueFolderIds: next }; + }); + }, + + setTaggingQueueFolderIds: (taggingQueueFolderIds) => { + set({ taggingQueueFolderIds }); + void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {}); + }, +}); diff --git a/src/store/types.ts b/src/store/types.ts new file mode 100644 index 0000000..751fe9d --- /dev/null +++ b/src/store/types.ts @@ -0,0 +1,327 @@ +export interface Folder { + id: number; + path: string; + name: string; + image_count: number; + indexed_at: string | null; + scan_error: string | null; + sort_order: number; +} + +export interface DirEntry { + name: string; + path: string; + has_children: boolean; +} + +export interface DirListing { + current: string | null; + parent: string | null; + entries: DirEntry[]; +} + +export type FolderAddResult = + | { status: "added"; data: Folder } + | { status: "skipped"; data: string } + | { status: "error"; data: string }; + +export type MediaKind = "image" | "video"; +export type MediaFilter = "all" | MediaKind; +export type ZoomPreset = "compact" | "comfortable" | "detail"; +export type SearchMode = "filename" | "semantic"; +export type SearchCommand = "filename" | "semantic" | "tag"; +export type CaptionAcceleration = "auto" | "cpu" | "directml"; +export type CaptionDetail = "short" | "detailed" | "paragraph"; +export type TaggerAcceleration = "auto" | "cpu" | "directml"; +export type TaggerModel = "wd" | "joytag"; +export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; +export type TaggingQueueScope = "all" | "selected"; +export type SimilarScope = "all_media" | "current_folder" | "current_album"; +export type ExploreMode = "visual" | "tags"; +export type AppTheme = "phokus" | "subtle-light" | "conventional-dark"; +export type SlideshowOrder = "sequential" | "random"; +export type SlideshowTransition = "soft-fade" | "gentle-motion"; + +export interface ImageRecord { + id: number; + folder_id: number; + path: string; + filename: string; + thumbnail_path: string | null; + width: number | null; + height: number | null; + file_size: number; + created_at: string | null; + modified_at: string | null; + taken_at: string | null; + mime_type: string; + media_kind: MediaKind; + duration_ms: number | null; + video_codec: string | null; + audio_codec: string | null; + metadata_updated_at: string | null; + metadata_error: string | null; + favorite: boolean; + rating: number; + embedding_status: string; + embedding_model: string | null; + embedding_updated_at: string | null; + embedding_error: string | null; + generated_caption: string | null; + caption_model: string | null; + caption_updated_at: string | null; + caption_error: string | null; + ai_rating: AiRating | null; + ai_tagger_model: string | null; + ai_tagged_at: string | null; + ai_tagger_error: string | null; +} + +export interface ImageTag { + id: number; + image_id: number; + tag: string; + source: "user" | "ai"; + ai_model: string | null; + confidence: number | null; + created_at: string; +} + +export interface DatabaseInfo { + size_mb: number; + reclaimable_mb: number; +} + +export interface VacuumResult { + before_mb: number; + after_mb: number; + freed_mb: number; +} + +export interface OrphanedThumbnailsInfo { + count: number; + size_mb: number; +} + +export interface CleanupOrphanedThumbnailsResult { + deleted_count: number; + freed_mb: number; +} + +export interface TaggerModelStatus { + model_id: string; + model_name: string; + local_dir: string; + ready: boolean; + missing_files: string[]; +} + +export interface TaggerModelProgress { + total_files: number; + completed_files: number; + current_file: string | null; + downloaded_bytes: number | null; + total_bytes: number | null; + done: boolean; +} + +export interface IndexProgress { + folder_id: number; + total: number; + indexed: number; + current_file: string; + done: boolean; +} + +export interface FolderJobProgress { + folder_id: number; + thumbnail_pending: number; + metadata_pending: number; + embedding_pending: number; + embedding_ready: number; + embedding_failed: number; + caption_pending: number; + caption_ready: number; + caption_failed: number; + tagging_pending: number; + tagging_ready: number; + tagging_failed: number; +} + +export interface MediaJobProgressEvent { + progress: FolderJobProgress[]; +} + +export interface IndexedImagesBatch { + folder_id: number; + images: ImageRecord[]; +} + +export interface ThumbnailBatch { + images: ImageRecord[]; +} + +export type ActiveView = "gallery" | "explore" | "duplicates" | "timeline" | "album"; + +export interface Album { + id: number; + name: string; + cover_image_id: number | null; + cover_thumbnail_path: string | null; + image_count: number; + sort_order: number; + created_at: string; + updated_at: string; +} + +export interface ImageExif { + make: string | null; + model: string | null; + lens: string | null; + iso: string | null; + f_number: string | null; + exposure_time: string | null; + focal_length: string | null; + datetime_original: string | null; + gps_lat: number | null; + gps_lon: number | null; +} + +export interface VisualClusterEntry { + count: number; + representative_image_id: number; + thumbnail_path: string | null; + image_ids: number[]; +} + +export interface ExploreTagEntry { + tag: string; + count: number; + representative_image_id: number; + thumbnail_path: string | null; + has_ai_source: boolean; + has_user_source: boolean; +} + +export interface RelatedTagEntry { + tag: string; + shared_count: number; +} + +export interface DuplicateGroup { + file_hash: string; + file_size: number; + images: ImageRecord[]; +} + +export interface DuplicateScanProgress { + phase: "checking" | "hashing" | "confirming"; + processed: number; + total: number; + skipped: number; +} + +export interface DuplicateScanResult { + groups: DuplicateGroup[]; + scanned_files: number; + candidate_files: number; + skipped_files: number; +} + +export interface SimilarImagesPage { + images: ImageRecord[]; + offset: number; + limit: number; + has_more: boolean; +} + +export interface CaptionModelStatus { + model_id: string; + model_name: string; + local_dir: string; + ready: boolean; + missing_files: string[]; +} + +export interface CaptionModelProgress { + total_files: number; + completed_files: number; + current_file: string | null; + done: boolean; +} + +export interface CaptionRuntimeSessionProbe { + file: string; + inputs: string[]; + outputs: string[]; +} + +export interface CaptionRuntimeProbe { + ready: boolean; + acceleration: CaptionAcceleration; + detail: CaptionDetail; + tokenizer_vocab_size: number; + sessions: CaptionRuntimeSessionProbe[]; +} + +export interface CaptionVisionProbe { + input_shape: number[]; + output_shape: number[]; + output_values: number; + acceleration: CaptionAcceleration; +} + +export interface TaggerRuntimeSessionProbe { + file: string; + inputs: string[]; + outputs: string[]; +} + +export interface TaggerRuntimeProbe { + ready: boolean; + acceleration: TaggerAcceleration; + session: TaggerRuntimeSessionProbe; +} + +export interface ParsedSearch { + mode: SearchCommand; + query: string; + prefix: string | null; +} + +export type SortOrder = + | "date_desc" + | "date_asc" + | "name_asc" + | "name_desc" + | "size_desc" + | "size_asc" + | "rating_desc" + | "rating_asc" + | "duration_desc" + | "duration_asc" + | "taken_desc" + | "taken_asc"; + +export type UpdateStatus = "idle" | "checking" | "upToDate" | "available" | "downloading" | "installing" | "error"; + +export type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging"; + +export const WORKER_KEYS: WorkerKey[] = ["thumbnail", "metadata", "embedding", "tagging"]; + +export interface FolderWorkerStates { + folder_id: number; + thumbnail_paused: boolean; + metadata_paused: boolean; + embedding_paused: boolean; + tagging_paused: boolean; +} + +export type FfmpegStatus = "unknown" | "starting" | "downloading" | "unpacking" | "installed" | "error"; + +export interface FfmpegProgressEvent { + phase: string; + downloaded_bytes: number | null; + total_bytes: number | null; + error: string | null; +} From fe312e7678deb42cc0ed09685c21af898fe262ed Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 16:33:54 +0100 Subject: [PATCH 13/27] refactor(lightbox): split Lightbox into smaller hooks and components Deconstruct the Lightbox.tsx god component into feature-specific hooks (slideshow, region selection, navigation, media details) and separate UI components (viewport, slideshow view, details panel). Extracted shared types and formatting logic into utility files. --- src/components/Lightbox.tsx | 1501 ++--------------- src/components/lightbox/LightboxAlbumMenu.tsx | 94 ++ .../lightbox/LightboxDetailsPanel.tsx | 437 +++++ src/components/lightbox/LightboxNavButton.tsx | 33 + src/components/lightbox/LightboxViewport.tsx | 164 ++ src/components/lightbox/SlideshowView.tsx | 174 ++ src/components/lightbox/format.ts | 56 + src/components/lightbox/types.ts | 26 + .../lightbox/useLightboxMediaDetails.ts | 84 + .../lightbox/useLightboxNavigation.ts | 109 ++ src/components/lightbox/useRegionSelection.ts | 169 ++ src/components/lightbox/useSlideshow.ts | 276 +++ src/components/lightbox/viewTransform.ts | 67 + 13 files changed, 1843 insertions(+), 1347 deletions(-) create mode 100644 src/components/lightbox/LightboxAlbumMenu.tsx create mode 100644 src/components/lightbox/LightboxDetailsPanel.tsx create mode 100644 src/components/lightbox/LightboxNavButton.tsx create mode 100644 src/components/lightbox/LightboxViewport.tsx create mode 100644 src/components/lightbox/SlideshowView.tsx create mode 100644 src/components/lightbox/format.ts create mode 100644 src/components/lightbox/types.ts create mode 100644 src/components/lightbox/useLightboxMediaDetails.ts create mode 100644 src/components/lightbox/useLightboxNavigation.ts create mode 100644 src/components/lightbox/useRegionSelection.ts create mode 100644 src/components/lightbox/useSlideshow.ts create mode 100644 src/components/lightbox/viewTransform.ts diff --git a/src/components/Lightbox.tsx b/src/components/Lightbox.tsx index 977c024..699d654 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,149 +1,16 @@ -import { useEffect, useCallback, useMemo, useRef, useState } from "react"; -import { motion, AnimatePresence, type Transition } from "framer-motion"; -import { invoke } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store"; -import { VideoPlayer } from "./VideoPlayer"; -import { mediaSrc } from "../lib/mediaSrc"; -import { Tooltip } from "./Tooltip"; -import { ChevronRightIcon, CloseIcon, StarIcon } from "./icons"; - -function formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function formatDate(iso: string | null): string { - if (!iso) return "Unknown"; - return new Date(iso).toLocaleDateString(undefined, { - year: "numeric", - month: "long", - day: "numeric", - }); -} - -function formatDuration(durationMs: number | null): string { - if (!durationMs || durationMs <= 0) return "Pending / unavailable"; - const totalSeconds = Math.floor(durationMs / 1000); - const seconds = totalSeconds % 60; - const minutes = Math.floor(totalSeconds / 60) % 60; - const hours = Math.floor(totalSeconds / 3600); - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; - } - - return `${minutes}:${seconds.toString().padStart(2, "0")}`; -} - -function embeddingLabel(status: string, model: string | null): string { - if (status === "ready") { - return model ? `Ready (${model})` : "Ready"; - } - if (status === "failed") { - return "Failed"; - } - if (status === "processing") { - return "Processing"; - } - return "Queued"; -} - -function ratingPill(rating: AiRating): { label: string; className: string } { - switch (rating) { - case "general": - return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" }; - case "sensitive": - return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" }; - case "questionable": - return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" }; - case "explicit": - return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" }; - } -} - -interface DragRect { - startX: number; - startY: number; - endX: number; - endY: number; -} - -/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */ -function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } { - return { - left: Math.min(r.startX, r.endX), - top: Math.min(r.startY, r.endY), - width: Math.abs(r.endX - r.startX), - height: Math.abs(r.endY - r.startY), - }; -} - -/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 0–1 crop coords - * relative to the actual rendered element bounds. */ -function rectToNormalisedCrop( - rect: DragRect, - imgEl: HTMLImageElement, -): { x: number; y: number; w: number; h: number } | null { - const imgBounds = imgEl.getBoundingClientRect(); - if (imgBounds.width === 0 || imgBounds.height === 0) return null; - - // rect coords are already in viewport space (client coords) - const rawX = Math.min(rect.startX, rect.endX); - const rawY = Math.min(rect.startY, rect.endY); - const rawW = Math.abs(rect.endX - rect.startX); - const rawH = Math.abs(rect.endY - rect.startY); - - // Clamp to image bounds - const clampedX = Math.max(rawX, imgBounds.left); - const clampedY = Math.max(rawY, imgBounds.top); - const clampedRight = Math.min(rawX + rawW, imgBounds.right); - const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom); - - const croppedW = clampedRight - clampedX; - const croppedH = clampedBottom - clampedY; - - if (croppedW <= 0 || croppedH <= 0) return null; - - // Normalize by the CSS transform scale — getBoundingClientRect already returns - // the scaled (on-screen) size, so we normalize directly against that. - return { - x: (clampedX - imgBounds.left) / imgBounds.width, - y: (clampedY - imgBounds.top) / imgBounds.height, - w: croppedW / imgBounds.width, - h: croppedH / imgBounds.height, - }; -} - -/** Minimum selection size as a fraction of the viewport container dimension. */ -const MIN_SELECTION_FRACTION = 0.02; - -const MIN_ZOOM = 0.5; -const MAX_ZOOM = 4; - -interface ViewTransform { - zoom: number; - panX: number; - panY: number; -} - -const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; -const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; -const SLIDESHOW_CONTROLS_IDLE_MS = 5000; -const SLIDESHOW_LOAD_MORE_THRESHOLD = 3; - -/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the - * viewport centre — stays under the cursor across a zoom change. */ -function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { - if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; - const ratio = newZoom / view.zoom; - return { - zoom: newZoom, - panX: anchorX - (anchorX - view.panX) * ratio, - panY: anchorY - (anchorY - view.panY) * ratio, - }; -} +import { useCallback, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { useGalleryStore } from "../store"; +import { LightboxDetailsPanel } from "./lightbox/LightboxDetailsPanel"; +import { LightboxNavButton } from "./lightbox/LightboxNavButton"; +import { LightboxViewport } from "./lightbox/LightboxViewport"; +import { SlideshowView } from "./lightbox/SlideshowView"; +import { useLightboxMediaDetails } from "./lightbox/useLightboxMediaDetails"; +import { useLightboxNavigation } from "./lightbox/useLightboxNavigation"; +import { useRegionSelection } from "./lightbox/useRegionSelection"; +import { useSlideshow } from "./lightbox/useSlideshow"; +import { ViewTransform } from "./lightbox/types"; +import { IDENTITY_VIEW } from "./lightbox/viewTransform"; export function Lightbox() { const selectedImage = useGalleryStore((state) => state.selectedImage); @@ -170,90 +37,12 @@ export function Lightbox() { const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); const slideshowTransition = useGalleryStore((state) => state.slideshowTransition); - // Tracks the image id that is currently displayed, used to discard async - // tag mutations that resolve after the user has navigated to another image. - const currentImageIdRef = useRef(null); - currentImageIdRef.current = selectedImage?.id ?? null; - - const [view, setView] = useState(IDENTITY_VIEW); - const zoom = view.zoom; - const [isPanning, setIsPanning] = useState(false); - const lastPanPointRef = useRef({ x: 0, y: 0 }); - const [imageTags, setImageTags] = useState([]); - const [imageExif, setImageExif] = useState(null); - const [tagInput, setTagInput] = useState(""); - const [tagAdding, setTagAdding] = useState(false); - const [tagsExpanded, setTagsExpanded] = useState(false); - const [taggingQueued, setTaggingQueued] = useState(false); - - // Region selection state - const [albumMenuOpen, setAlbumMenuOpen] = useState(false); - const [albumAddedTo, setAlbumAddedTo] = useState(null); - const [newAlbumName, setNewAlbumName] = useState(""); - const [albumAdding, setAlbumAdding] = useState(false); - const [regionSelectMode, setRegionSelectMode] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const [dragRect, setDragRect] = useState(null); - const [regionSearching, setRegionSearching] = useState(false); - const [slideshowActive, setSlideshowActive] = useState(false); - const [slideshowPaused, setSlideshowPaused] = useState(false); - const [slideshowControlsVisible, setSlideshowControlsVisible] = useState(true); - const [slideshowLoadingMore, setSlideshowLoadingMore] = useState(false); - const [slideshowMotionStep, setSlideshowMotionStep] = useState(0); - const lightboxRootRef = useRef(null); const imageViewportRef = useRef(null); const imgRef = useRef(null); - const slideshowControlsIdleTimeoutRef = useRef(null); - const slideshowPointerRef = useRef<{ x: number; y: number } | null>(null); - const slideshowRandomSeenRef = useRef>(new Set()); + const [view, setView] = useState(IDENTITY_VIEW); const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; - const slideshowImages = useMemo( - () => images.filter((image) => image.media_kind === "image"), - [images], - ); - const slideshowIndex = selectedImage - ? slideshowImages.findIndex((image) => image.id === selectedImage.id) - : -1; - const slideshowPosition = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1); - const slideshowControlsShown = slideshowControlsVisible; - const slideshowMotionDirections = [ - { x: -1, y: 0.45 }, - { x: 1, y: -0.45 }, - { x: -0.7, y: -0.55 }, - { x: 0.7, y: 0.55 }, - ]; - const slideshowMotionDirection = - slideshowMotionDirections[slideshowMotionStep % slideshowMotionDirections.length]; - const slideshowImageInitial = { opacity: 0, filter: "blur(8px)" }; - const slideshowImageAnimate = { opacity: 1, filter: "blur(0px)" }; - const slideshowImageExit = { opacity: 0, filter: "blur(4px)" }; - const slideshowImageTransition: Transition = { duration: 0.72, ease: SLIDESHOW_EASE }; - const slideshowImageContentInitial = - slideshowTransition === "gentle-motion" - ? { - scale: 1.012, - x: -8 * slideshowMotionDirection.x, - y: 8 * slideshowMotionDirection.y, - } - : { scale: 1.012 }; - const slideshowImageContentAnimate = - slideshowTransition === "gentle-motion" - ? { - scale: 1.026, - x: 8 * slideshowMotionDirection.x, - y: -8 * slideshowMotionDirection.y, - } - : { scale: 1 }; - const slideshowImageContentTransition: Transition = - slideshowTransition === "gentle-motion" - ? { - duration: slideshowIntervalSeconds + 0.75, - ease: "linear", - } - : { duration: 0.72, ease: SLIDESHOW_EASE }; - const canStartSlideshow = slideshowImages.length > 0; const canFindSimilar = selectedImage?.embedding_status === "ready"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; const taggerReady = taggerModelStatus?.ready ?? false; @@ -264,412 +53,71 @@ export function Lightbox() { ? "Queue AI tagging for this image" : "AI tagger model not installed"; - const goPrev = useCallback(() => { - if (currentIndex > 0) openImage(images[currentIndex - 1]); - }, [currentIndex, images, openImage]); - - const goNext = useCallback(() => { - if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); - }, [currentIndex, images, openImage]); - - const exitRegionMode = useCallback(() => { - setRegionSelectMode(false); - setIsDragging(false); - setDragRect(null); - }, []); - - const showSlideshowControls = useCallback(() => { - if (slideshowControlsIdleTimeoutRef.current !== null) { - window.clearTimeout(slideshowControlsIdleTimeoutRef.current); - slideshowControlsIdleTimeoutRef.current = null; - } - setSlideshowControlsVisible(true); - if (slideshowActive) { - slideshowControlsIdleTimeoutRef.current = window.setTimeout(() => { - setSlideshowControlsVisible(false); - slideshowControlsIdleTimeoutRef.current = null; - }, SLIDESHOW_CONTROLS_IDLE_MS); - } - }, [slideshowActive]); - - const exitSlideshow = useCallback(() => { - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - if (document.fullscreenElement === lightboxRootRef.current) { - void document.exitFullscreen().catch(() => undefined); - } - }, []); - - const goSlideshow = useCallback( - (direction: -1 | 1, revealControls = true) => { - if (slideshowImages.length === 0) return; - if (slideshowImages.length === 1) { - openImage(slideshowImages[0]); - return; - } - - const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0; - let nextIndex = - (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; - if (slideshowOrder === "random") { - const currentId = slideshowImages[currentSlideshowIndex]?.id; - let candidates = slideshowImages.filter( - (image) => image.id !== currentId && !slideshowRandomSeenRef.current.has(image.id), - ); - if (candidates.length === 0) { - slideshowRandomSeenRef.current = new Set(currentId == null ? [] : [currentId]); - candidates = slideshowImages.filter((image) => image.id !== currentId); - } - const nextImage = candidates[Math.floor(Math.random() * candidates.length)]; - nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id); - slideshowRandomSeenRef.current.add(nextImage.id); - } - setSlideshowMotionStep((step) => step + 1); - openImage(slideshowImages[nextIndex]); - if (revealControls) { - showSlideshowControls(); - } - }, - [openImage, showSlideshowControls, slideshowImages, slideshowIndex, slideshowOrder], - ); - - const handleSlideshowPointerMove = useCallback( - (event: React.PointerEvent) => { - const previous = slideshowPointerRef.current; - const next = { x: event.clientX, y: event.clientY }; - slideshowPointerRef.current = next; - - if (!previous) { - if (!slideshowControlsVisible) { - showSlideshowControls(); - } - return; - } - - if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) { - showSlideshowControls(); - } - }, - [showSlideshowControls, slideshowControlsVisible], - ); - - const startSlideshow = useCallback(() => { - if (!selectedImage || slideshowImages.length === 0) return; - - const nextImage = - selectedImage.media_kind === "image" - ? selectedImage - : images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ?? - slideshowImages[0]; - - if (nextImage.id !== selectedImage.id) { - openImage(nextImage); - } - - slideshowRandomSeenRef.current = new Set([nextImage.id]); - setSlideshowMotionStep(0); - exitRegionMode(); - setView(IDENTITY_VIEW); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - slideshowPointerRef.current = null; - setSlideshowActive(true); - void lightboxRootRef.current?.requestFullscreen().catch(() => undefined); - }, [currentIndex, exitRegionMode, images, openImage, selectedImage, slideshowImages]); - - // Keep the pan within bounds: when the scaled image overflows the viewport - // the image edge may not be dragged past the viewport edge; when it fits, - // the pan snaps back to centre on that axis. - const clampPan = useCallback((v: ViewTransform): ViewTransform => { - const img = imgRef.current; - const vp = imageViewportRef.current; - if (!img || !vp) return v; - const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2); - const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2); - return { - ...v, - panX: Math.min(maxX, Math.max(-maxX, v.panX)), - panY: Math.min(maxY, Math.max(-maxY, v.panY)), - }; - }, []); - - useEffect(() => { - setView(IDENTITY_VIEW); - setImageTags([]); - setImageExif(null); - setTagInput(""); - setTagsExpanded(false); - setTaggingQueued(false); - exitRegionMode(); - setRegionSearching(false); - }, [selectedImage?.id, exitRegionMode]); - - useEffect(() => { - if (!selectedImage) return; - // Capture the ID so a stale response for image A cannot overwrite B's tags - // when the user navigates before the request resolves. - let cancelled = false; - void getImageTags(selectedImage.id) - .then((tags) => { if (!cancelled) setImageTags(tags); }) - .catch(() => { if (!cancelled) setImageTags([]); }); - return () => { cancelled = true; }; - }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); - - // EXIF is read on demand from the file (not stored), so it works on every - // already-indexed image without a reindex. Only meaningful for images. - useEffect(() => { - if (!selectedImage || selectedImage.media_kind !== "image") { - setImageExif(null); - return; - } - let cancelled = false; - void getImageExif(selectedImage.id) - .then((exif) => { if (!cancelled) setImageExif(exif); }) - .catch(() => { if (!cancelled) setImageExif(null); }); - return () => { cancelled = true; }; - }, [selectedImage?.id, selectedImage?.media_kind, getImageExif]); - - useEffect(() => { - if (selectedImage?.media_kind !== "image" || taggerStatusKnown) return; - void loadTaggerModelStatus(); - }, [loadTaggerModelStatus, selectedImage?.media_kind, taggerStatusKnown]); - - // Reset the queued state once the worker finishes so the button is usable again - useEffect(() => { - if (selectedImage?.ai_tagged_at) setTaggingQueued(false); - }, [selectedImage?.ai_tagged_at]); - - useEffect(() => { - if (selectedImage) return; - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - }, [selectedImage]); - - useEffect(() => { - const handleFullscreenChange = () => { - if (!slideshowActive) return; - if (document.fullscreenElement !== lightboxRootRef.current) { - setSlideshowActive(false); - setSlideshowPaused(false); - setSlideshowControlsVisible(true); - } - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); - }, [slideshowActive]); - - useEffect(() => { - showSlideshowControls(); - return () => { - if (slideshowControlsIdleTimeoutRef.current !== null) { - window.clearTimeout(slideshowControlsIdleTimeoutRef.current); - slideshowControlsIdleTimeoutRef.current = null; - } - }; - }, [showSlideshowControls, slideshowActive, slideshowPaused]); - - useEffect(() => { - if (!slideshowActive || slideshowPaused || slideshowImages.length <= 1) return; - const timeout = window.setTimeout(() => { - goSlideshow(1, false); - }, slideshowIntervalSeconds * 1000); - return () => window.clearTimeout(timeout); - }, [goSlideshow, selectedImage?.id, slideshowActive, slideshowImages.length, slideshowIntervalSeconds, slideshowPaused]); - - useEffect(() => { - if ( - !slideshowActive || - slideshowLoadingMore || - loadedCount >= totalImages || - slideshowImages.length === 0 || - slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD - ) { - return; - } - - setSlideshowLoadingMore(true); - void loadMoreImages().finally(() => setSlideshowLoadingMore(false)); - }, [ - loadedCount, - loadMoreImages, - slideshowActive, - slideshowImages.length, - slideshowIndex, - slideshowLoadingMore, - totalImages, - ]); - - useEffect(() => { - const viewport = imageViewportRef.current; - if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return; - - const handleWheel = (event: WheelEvent) => { - if (regionSelectMode) return; // don't zoom during selection - if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; - event.preventDefault(); - const bounds = viewport.getBoundingClientRect(); - const anchorX = event.clientX - (bounds.left + bounds.width / 2); - const anchorY = event.clientY - (bounds.top + bounds.height / 2); - setView((v) => { - const delta = event.deltaY < 0 ? 0.15 : -0.15; - const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta)); - return clampPan(zoomViewAt(v, next, anchorX, anchorY)); - }); - }; - - viewport.addEventListener("wheel", handleWheel, { passive: false }); - return () => viewport.removeEventListener("wheel", handleWheel); - }, [selectedImage, regionSelectMode, clampPan, slideshowActive]); - - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (!selectedImage) return; - if (slideshowActive) { - if (event.key === "Escape") { - event.preventDefault(); - exitSlideshow(); - return; - } - if (event.key === " ") { - event.preventDefault(); - setSlideshowPaused((paused) => !paused); - showSlideshowControls(); - return; - } - if (event.key === "ArrowLeft" && !event.shiftKey) { - event.preventDefault(); - goSlideshow(-1); - return; - } - if (event.key === "ArrowRight" && !event.shiftKey) { - event.preventDefault(); - goSlideshow(1); - return; - } - return; - } - if (event.key === "Escape") { - if (regionSelectMode) { - exitRegionMode(); - } else { - closeImage(); - } - } - if (regionSelectMode) return; // block nav keys during selection - // Shift+arrows are reserved for video seeking (handled by VideoPlayer) - if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); - if (event.key === "ArrowRight" && !event.shiftKey) goNext(); - if (event.key === "+" || event.key === "=") - setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0))); - if (event.key === "-") - setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0))); - }; - - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [ + const region = useRegionSelection({ selectedImage, + slideshowActive: false, + imageViewportRef, + imgRef, + view, + setView, + findSimilarByRegion, + }); + + const slideshow = useSlideshow({ + rootRef: lightboxRootRef, + selectedImage, + images, + currentIndex, + loadedCount, + totalImages, + intervalSeconds: slideshowIntervalSeconds, + order: slideshowOrder, + transition: slideshowTransition, + openImage, + loadMoreImages, + exitRegionMode: region.exitRegionMode, + setView, + }); + + const resetForSelectedImage = useCallback(() => { + setView(IDENTITY_VIEW); + region.exitRegionMode(); + region.setRegionSearching(false); + }, [region.exitRegionMode, region.setRegionSearching]); + + const details = useLightboxMediaDetails({ + selectedImage, + taggerModelStatus, + getImageTags, + getImageExif, + loadTaggerModelStatus, + onSelectedImageReset: resetForSelectedImage, + }); + + const { goPrev, goNext } = useLightboxNavigation({ + selectedImage, + images, + currentIndex, + slideshowActive: slideshow.active, + regionSelectMode: region.regionSelectMode, closeImage, - exitSlideshow, - goPrev, - goNext, - goSlideshow, - regionSelectMode, - exitRegionMode, - clampPan, - showSlideshowControls, - slideshowActive, - ]); + exitRegionMode: region.exitRegionMode, + exitSlideshow: slideshow.exit, + goSlideshow: slideshow.go, + showSlideshowControls: slideshow.showControls, + setSlideshowPaused: slideshow.setPaused, + openImage, + setView, + clampPan: region.clampPan, + }); - // ── Region selection / pan pointer handlers ───────────────────────────────── - const handleRegionPointerDown = useCallback( - (event: React.PointerEvent) => { - if (!regionSelectMode) { - // Drag-to-pan when the image is zoomed in - if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - lastPanPointRef.current = { x: event.clientX, y: event.clientY }; - setIsPanning(true); - } - return; - } - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - setIsDragging(true); - setDragRect({ - startX: event.clientX, - startY: event.clientY, - endX: event.clientX, - endY: event.clientY, - }); - }, - [regionSelectMode, zoom, selectedImage?.media_kind], - ); - - const handleRegionPointerMove = useCallback( - (event: React.PointerEvent) => { - if (isPanning) { - const dx = event.clientX - lastPanPointRef.current.x; - const dy = event.clientY - lastPanPointRef.current.y; - lastPanPointRef.current = { x: event.clientX, y: event.clientY }; - setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy })); - return; - } - if (!isDragging) return; - setDragRect((prev) => - prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, - ); - }, - [isDragging, isPanning, clampPan], - ); - - const handleRegionPointerUp = useCallback( - (event: React.PointerEvent) => { - if (isPanning) { - event.currentTarget.releasePointerCapture(event.pointerId); - setIsPanning(false); - return; - } - if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { - setIsDragging(false); - return; - } - event.currentTarget.releasePointerCapture(event.pointerId); - - const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }; - const crop = rectToNormalisedCrop(finalRect, imgRef.current); - - setIsDragging(false); - setDragRect(null); - - // Ignore tiny accidental clicks - const containerBounds = imageViewportRef.current?.getBoundingClientRect(); - const containerSize = containerBounds - ? Math.min(containerBounds.width, containerBounds.height) - : 500; - const selW = Math.abs(finalRect.endX - finalRect.startX); - const selH = Math.abs(finalRect.endY - finalRect.startY); - if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) { - exitRegionMode(); - return; - } - - exitRegionMode(); - setRegionSearching(true); - - void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id) - .finally(() => setRegionSearching(false)); - }, - [isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode], - ); - - // Build the CSS rect for the selection overlay (viewport-relative) - const selectionOverlay = - isDragging && dragRect ? normaliseRect(dragRect) : null; + const toggleRegionMode = useCallback(() => { + if (region.regionSelectMode) { + region.exitRegionMode(); + } else { + region.setRegionSelectMode(true); + } + }, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]); return ( @@ -678,739 +126,98 @@ export function Lightbox() { ref={lightboxRootRef} key="lightbox" className={`media-dark-surface fixed inset-0 z-50 flex ${ - slideshowActive ? "bg-black" : "bg-black/90 backdrop-blur-sm" + slideshow.active ? "bg-black" : "bg-black/90 backdrop-blur-sm" }`} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15 }} - onClick={slideshowActive ? showSlideshowControls : regionSelectMode ? undefined : closeImage} + onClick={slideshow.active ? slideshow.showControls : region.regionSelectMode ? undefined : closeImage} > - {slideshowActive ? ( -
{ - event.stopPropagation(); - showSlideshowControls(); - }} - onPointerMove={handleSlideshowPointerMove} - > - - {selectedImage.media_kind === "image" ? ( - - - - ) : ( - - Finding next image… - - )} - - - -
- {slideshowPosition} - / - {slideshowImages.length} - - {selectedImage.filename} -
- - - -
- - -
- - - - - - - - - -
-
- - {slideshowLoadingMore ? ( -
- Loading more… -
- ) : null} -
+ {slideshow.active ? ( + slideshow.setPaused((paused) => !paused)} + /> ) : ( <> - + -
event.stopPropagation()}> -
-
1 && selectedImage.media_kind === "image" - ? "cursor-grab" - : "" - }`} - onPointerDown={handleRegionPointerDown} - onPointerMove={handleRegionPointerMove} - onPointerUp={handleRegionPointerUp} - > - {/* Region selection mode hint */} - {regionSelectMode && ( -
-
- - - - Draw a region to search — Esc to cancel -
-
- )} - - {/* Selection rectangle overlay */} - {selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && ( -
event.stopPropagation()}> +
+ - )} - - - {selectedImage.media_kind === "video" ? ( - - ) : ( - <> - {selectedImage.filename} - - )} - - - - {!regionSelectMode && ( -
-
- - - - {selectedImage.media_kind === "image" ? ( - <> -
- - {Math.round(zoom * 100)}% - - - ) : null} -
-
- )} -
- -
-
-
-

{selectedImage.filename}

-

Details

-
-
- - - - - - -
- -
- - {/* Search region button row */} - {canSearchRegion && ( -
- - - -
- )} - -
-
-
-

Rating

-
- {Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; - return ( - - - - ); - })} - {selectedImage.rating > 0 ? ( - - - - ) : null} -
-
- -
-

Dimensions

-

- {selectedImage.width && selectedImage.height - ? `${selectedImage.width} x ${selectedImage.height}px` - : "Pending / unavailable"} -

-
- - {selectedImage.media_kind === "video" ? ( - <> -
-

Duration

-

{formatDuration(selectedImage.duration_ms)}

-
- -
-

Video codec

-

{selectedImage.video_codec ?? "Pending / unavailable"}

-
- -
-

Audio codec

-

{selectedImage.audio_codec ?? "None / unavailable"}

-
- - {selectedImage.metadata_error ? ( -
-

Metadata

-

{selectedImage.metadata_error}

-
- ) : null} - - ) : null} - -
-

Type

-

{selectedImage.mime_type}

-
- -
-

File size

-

{formatBytes(selectedImage.file_size)}

-
- -
-

Modified

-

{formatDate(selectedImage.modified_at)}

-
- -
-

Embedding

-

{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}

- {selectedImage.embedding_error ? ( -

{selectedImage.embedding_error}

- ) : null} -
-
- -
-
-

Tags

-
- {selectedImage.ai_rating ? ( - - {ratingPill(selectedImage.ai_rating).label} - - ) : null} - {selectedImage.media_kind === "image" ? ( - - - ) : null} -
-
- - {imageTags.length > 0 ? ( - <> -
- {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => ( - - - {t.tag} - - - - - - ))} -
- {imageTags.length > 8 && ( - - )} - - ) : ( -

No tags yet

- )} - -
{ - e.preventDefault(); - const raw = tagInput.trim(); - if (!raw || tagAdding) return; - setTagAdding(true); - const taggedImageId = selectedImage.id; - void addUserTag(taggedImageId, raw) - .then((newTag) => { - // Discard if the user navigated away before the request resolved. - if (currentImageIdRef.current !== taggedImageId) return; - setImageTags((prev) => [...prev, newTag]); - setTagInput(""); - }) - .catch(() => undefined) - .finally(() => setTagAdding(false)); - }} - > - setTagInput(e.target.value)} - disabled={tagAdding} - /> - -
-
- -
-
-

Albums

- -
- {albumMenuOpen ? ( -
-
- {albums.length === 0 ? ( -

No albums yet — create one below.

- ) : ( - albums.map((album) => ( - - )) - )} -
-
{ - e.preventDefault(); - const name = newAlbumName.trim(); - if (!name || albumAdding) return; - setAlbumAdding(true); - void createAlbum(name) - .then(async (album) => { - await addToAlbum(album.id, [selectedImage.id]); - setAlbumAddedTo(album.id); - setNewAlbumName(""); - }) - .catch(() => undefined) - .finally(() => setAlbumAdding(false)); - }} - > - setNewAlbumName(e.target.value)} - disabled={albumAdding} - /> - -
-
- ) : null} -
- - {imageExif && - (imageExif.make || - imageExif.model || - imageExif.lens || - imageExif.f_number || - imageExif.exposure_time || - imageExif.iso || - imageExif.focal_length || - (imageExif.gps_lat != null && imageExif.gps_lon != null)) ? ( -
-

Camera

-
- {imageExif.make || imageExif.model ? ( -

- {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} -

- ) : null} - {imageExif.lens ?

{imageExif.lens}

: null} - {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( -
- {imageExif.f_number ? {imageExif.f_number} : null} - {imageExif.exposure_time ? {imageExif.exposure_time} : null} - {imageExif.iso ? ISO {imageExif.iso} : null} - {imageExif.focal_length ? {imageExif.focal_length} : null} -
- ) : null} - {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( - - - - ) : null} -
-
- ) : null} - -
-
-

Path

- - - -
-

{selectedImage.path}

-
-
- -
- {currentIndex + 1} / {images.length} +
-
-
- + = images.length - 1 || region.regionSelectMode} + onClick={goNext} + /> )} diff --git a/src/components/lightbox/LightboxAlbumMenu.tsx b/src/components/lightbox/LightboxAlbumMenu.tsx new file mode 100644 index 0000000..b61b620 --- /dev/null +++ b/src/components/lightbox/LightboxAlbumMenu.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { Album } from "../../store"; + +interface LightboxAlbumMenuProps { + imageId: number; + albums: Album[]; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + createAlbum: (name: string) => Promise; +} + +export function LightboxAlbumMenu({ imageId, albums, addToAlbum, createAlbum }: LightboxAlbumMenuProps) { + const [albumMenuOpen, setAlbumMenuOpen] = useState(false); + const [albumAddedTo, setAlbumAddedTo] = useState(null); + const [newAlbumName, setNewAlbumName] = useState(""); + const [albumAdding, setAlbumAdding] = useState(false); + + return ( +
+
+

Albums

+ +
+ {albumMenuOpen ? ( +
+
+ {albums.length === 0 ? ( +

No albums yet — create one below.

+ ) : ( + albums.map((album) => ( + + )) + )} +
+
{ + event.preventDefault(); + const name = newAlbumName.trim(); + if (!name || albumAdding) return; + setAlbumAdding(true); + void createAlbum(name) + .then(async (album) => { + await addToAlbum(album.id, [imageId]); + setAlbumAddedTo(album.id); + setNewAlbumName(""); + }) + .catch(() => undefined) + .finally(() => setAlbumAdding(false)); + }} + > + setNewAlbumName(event.target.value)} + disabled={albumAdding} + /> + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/lightbox/LightboxDetailsPanel.tsx b/src/components/lightbox/LightboxDetailsPanel.tsx new file mode 100644 index 0000000..f6a1af2 --- /dev/null +++ b/src/components/lightbox/LightboxDetailsPanel.tsx @@ -0,0 +1,437 @@ +import { MutableRefObject } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { Album, ImageExif, ImageRecord, ImageTag } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { CloseIcon, StarIcon } from "../icons"; +import { embeddingLabel, formatBytes, formatDate, formatDuration, ratingPill } from "./format"; +import { LightboxAlbumMenu } from "./LightboxAlbumMenu"; + +interface LightboxDetailsPanelProps { + selectedImage: ImageRecord; + currentIndex: number; + imageCount: number; + imageTags: ImageTag[]; + setImageTags: React.Dispatch>; + imageExif: ImageExif | null; + tagInput: string; + setTagInput: React.Dispatch>; + tagAdding: boolean; + setTagAdding: React.Dispatch>; + tagsExpanded: boolean; + setTagsExpanded: React.Dispatch>; + taggingQueued: boolean; + setTaggingQueued: React.Dispatch>; + currentImageIdRef: MutableRefObject; + albums: Album[]; + canFindSimilar: boolean; + canSearchRegion: boolean; + regionSelectMode: boolean; + regionSearching: boolean; + taggerReady: boolean; + taggerButtonTooltip: string; + closeImage: () => void; + findSimilar: (imageId: number, sourceFolderId: number | null) => Promise; + updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; + addUserTag: (imageId: number, tag: string) => Promise; + removeTag: (tagId: number) => Promise; + queueTaggingForImage: (imageId: number) => Promise; + addToAlbum: (albumId: number, imageIds: number[]) => Promise; + createAlbum: (name: string) => Promise; + onToggleRegionMode: () => void; +} + +export function LightboxDetailsPanel({ + selectedImage, + currentIndex, + imageCount, + imageTags, + setImageTags, + imageExif, + tagInput, + setTagInput, + tagAdding, + setTagAdding, + tagsExpanded, + setTagsExpanded, + taggingQueued, + setTaggingQueued, + currentImageIdRef, + albums, + canFindSimilar, + canSearchRegion, + regionSelectMode, + regionSearching, + taggerReady, + taggerButtonTooltip, + closeImage, + findSimilar, + updateImageDetails, + addUserTag, + removeTag, + queueTaggingForImage, + addToAlbum, + createAlbum, + onToggleRegionMode, +}: LightboxDetailsPanelProps) { + const aiRating = selectedImage.ai_rating ? ratingPill(selectedImage.ai_rating) : null; + const hasCameraInfo = + imageExif && + (imageExif.make || + imageExif.model || + imageExif.lens || + imageExif.f_number || + imageExif.exposure_time || + imageExif.iso || + imageExif.focal_length || + (imageExif.gps_lat != null && imageExif.gps_lon != null)); + + return ( +
+
+
+

{selectedImage.filename}

+

Details

+
+
+ + + + + + +
+ +
+ + {canSearchRegion && ( +
+ + + +
+ )} + +
+
+
+

Rating

+
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1; + return ( + + + + ); + })} + {selectedImage.rating > 0 ? ( + + + + ) : null} +
+
+ +
+

Dimensions

+

+ {selectedImage.width && selectedImage.height + ? `${selectedImage.width} x ${selectedImage.height}px` + : "Pending / unavailable"} +

+
+ + {selectedImage.media_kind === "video" ? ( + <> +
+

Duration

+

{formatDuration(selectedImage.duration_ms)}

+
+
+

Video codec

+

{selectedImage.video_codec ?? "Pending / unavailable"}

+
+
+

Audio codec

+

{selectedImage.audio_codec ?? "None / unavailable"}

+
+ {selectedImage.metadata_error ? ( +
+

Metadata

+

{selectedImage.metadata_error}

+
+ ) : null} + + ) : null} + +
+

Type

+

{selectedImage.mime_type}

+
+
+

File size

+

{formatBytes(selectedImage.file_size)}

+
+
+

Modified

+

{formatDate(selectedImage.modified_at)}

+
+
+

Embedding

+

{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}

+ {selectedImage.embedding_error ? ( +

{selectedImage.embedding_error}

+ ) : null} +
+
+ +
+
+

Tags

+
+ {aiRating ? ( + + {aiRating.label} + + ) : null} + {selectedImage.media_kind === "image" ? ( + + + + ) : null} +
+
+ + {imageTags.length > 0 ? ( + <> +
+ {(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((tag) => ( + + + {tag.tag} + + + + + + ))} +
+ {imageTags.length > 8 && ( + + )} + + ) : ( +

No tags yet

+ )} + +
{ + event.preventDefault(); + const raw = tagInput.trim(); + if (!raw || tagAdding) return; + setTagAdding(true); + const taggedImageId = selectedImage.id; + void addUserTag(taggedImageId, raw) + .then((newTag) => { + if (currentImageIdRef.current !== taggedImageId) return; + setImageTags((prev) => [...prev, newTag]); + setTagInput(""); + }) + .catch(() => undefined) + .finally(() => setTagAdding(false)); + }} + > + setTagInput(event.target.value)} + disabled={tagAdding} + /> + +
+
+ + + + {hasCameraInfo ? ( +
+

Camera

+
+ {imageExif.make || imageExif.model ? ( +

+ {[imageExif.make, imageExif.model].filter(Boolean).join(" ")} +

+ ) : null} + {imageExif.lens ?

{imageExif.lens}

: null} + {imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? ( +
+ {imageExif.f_number ? {imageExif.f_number} : null} + {imageExif.exposure_time ? {imageExif.exposure_time} : null} + {imageExif.iso ? ISO {imageExif.iso} : null} + {imageExif.focal_length ? {imageExif.focal_length} : null} +
+ ) : null} + {imageExif.gps_lat != null && imageExif.gps_lon != null ? ( + + + + ) : null} +
+
+ ) : null} + +
+
+

Path

+ + + +
+

{selectedImage.path}

+
+
+ +
+ {currentIndex + 1} / {imageCount} +
+
+ ); +} diff --git a/src/components/lightbox/LightboxNavButton.tsx b/src/components/lightbox/LightboxNavButton.tsx new file mode 100644 index 0000000..006cf16 --- /dev/null +++ b/src/components/lightbox/LightboxNavButton.tsx @@ -0,0 +1,33 @@ +import { ChevronRightIcon } from "../icons"; + +interface LightboxNavButtonProps { + direction: "previous" | "next"; + disabled: boolean; + onClick: () => void; +} + +export function LightboxNavButton({ direction, disabled, onClick }: LightboxNavButtonProps) { + const isNext = direction === "next"; + + return ( + + ); +} diff --git a/src/components/lightbox/LightboxViewport.tsx b/src/components/lightbox/LightboxViewport.tsx new file mode 100644 index 0000000..31a2748 --- /dev/null +++ b/src/components/lightbox/LightboxViewport.tsx @@ -0,0 +1,164 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { RefObject } from "react"; +import { ImageRecord } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { VideoPlayer } from "../VideoPlayer"; +import { Tooltip } from "../Tooltip"; +import { SelectionOverlay, ViewTransform } from "./types"; +import { MAX_ZOOM, MIN_ZOOM, zoomViewAt } from "./viewTransform"; + +interface LightboxViewportProps { + selectedImage: ImageRecord; + imageViewportRef: RefObject; + imgRef: RefObject; + view: ViewTransform; + zoom: number; + regionSelectMode: boolean; + isPanning: boolean; + selectionOverlay: SelectionOverlay | null; + canStartSlideshow: boolean; + onStartSlideshow: () => void; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + setView: React.Dispatch>; + clampPan: (view: ViewTransform) => ViewTransform; +} + +export function LightboxViewport({ + selectedImage, + imageViewportRef, + imgRef, + view, + zoom, + regionSelectMode, + isPanning, + selectionOverlay, + canStartSlideshow, + onStartSlideshow, + onPointerDown, + onPointerMove, + onPointerUp, + setView, + clampPan, +}: LightboxViewportProps) { + return ( +
1 && selectedImage.media_kind === "image" + ? "cursor-grab" + : "" + }`} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + > + {regionSelectMode && ( +
+
+ + + + Draw a region to search — Esc to cancel +
+
+ )} + + {selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && ( +
+ )} + + + + {selectedImage.media_kind === "video" ? ( + + ) : ( + {selectedImage.filename} + )} + + + + {!regionSelectMode && ( +
+
+ + + + {selectedImage.media_kind === "image" ? ( + <> +
+ + {Math.round(zoom * 100)}% + + + ) : null} +
+
+ )} +
+ ); +} diff --git a/src/components/lightbox/SlideshowView.tsx b/src/components/lightbox/SlideshowView.tsx new file mode 100644 index 0000000..8840a0c --- /dev/null +++ b/src/components/lightbox/SlideshowView.tsx @@ -0,0 +1,174 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { ImageRecord } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { Tooltip } from "../Tooltip"; +import { ChevronRightIcon, CloseIcon } from "../icons"; +import { SlideshowMotion } from "./useSlideshow"; + +interface SlideshowViewProps { + selectedImage: ImageRecord; + imageCount: number; + position: number; + controlsShown: boolean; + paused: boolean; + loadingMore: boolean; + motionConfig: SlideshowMotion; + onPointerMove: (event: React.PointerEvent) => void; + onShowControls: () => void; + onExit: () => void; + onGo: (direction: -1 | 1) => void; + onTogglePaused: () => void; +} + +export function SlideshowView({ + selectedImage, + imageCount, + position, + controlsShown, + paused, + loadingMore, + motionConfig, + onPointerMove, + onShowControls, + onExit, + onGo, + onTogglePaused, +}: SlideshowViewProps) { + return ( +
{ + event.stopPropagation(); + onShowControls(); + }} + onPointerMove={onPointerMove} + > + + {selectedImage.media_kind === "image" ? ( + + + + ) : ( + + Finding next image… + + )} + + + +
+ {position} + / + {imageCount} + + {selectedImage.filename} +
+ + + +
+ + +
+ + + + + + + + + +
+
+ + {loadingMore ? ( +
+ Loading more… +
+ ) : null} +
+ ); +} diff --git a/src/components/lightbox/format.ts b/src/components/lightbox/format.ts new file mode 100644 index 0000000..c21c28b --- /dev/null +++ b/src/components/lightbox/format.ts @@ -0,0 +1,56 @@ +import { AiRating } from "../../store"; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDate(iso: string | null): string { + if (!iso) return "Unknown"; + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export function formatDuration(durationMs: number | null): string { + if (!durationMs || durationMs <= 0) return "Pending / unavailable"; + const totalSeconds = Math.floor(durationMs / 1000); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60) % 60; + const hours = Math.floor(totalSeconds / 3600); + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + } + + return `${minutes}:${seconds.toString().padStart(2, "0")}`; +} + +export function embeddingLabel(status: string, model: string | null): string { + if (status === "ready") { + return model ? `Ready (${model})` : "Ready"; + } + if (status === "failed") { + return "Failed"; + } + if (status === "processing") { + return "Processing"; + } + return "Queued"; +} + +export function ratingPill(rating: AiRating): { label: string; className: string } { + switch (rating) { + case "general": + return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" }; + case "sensitive": + return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" }; + case "questionable": + return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" }; + case "explicit": + return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" }; + } +} diff --git a/src/components/lightbox/types.ts b/src/components/lightbox/types.ts new file mode 100644 index 0000000..be266cc --- /dev/null +++ b/src/components/lightbox/types.ts @@ -0,0 +1,26 @@ +export interface DragRect { + startX: number; + startY: number; + endX: number; + endY: number; +} + +export interface ViewTransform { + zoom: number; + panX: number; + panY: number; +} + +export interface NormalizedCrop { + x: number; + y: number; + w: number; + h: number; +} + +export interface SelectionOverlay { + left: number; + top: number; + width: number; + height: number; +} diff --git a/src/components/lightbox/useLightboxMediaDetails.ts b/src/components/lightbox/useLightboxMediaDetails.ts new file mode 100644 index 0000000..f9547e5 --- /dev/null +++ b/src/components/lightbox/useLightboxMediaDetails.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from "react"; +import { ImageExif, ImageRecord, ImageTag, TaggerModelStatus } from "../../store"; + +interface UseLightboxMediaDetailsParams { + selectedImage: ImageRecord | null; + taggerModelStatus: TaggerModelStatus | null; + getImageTags: (imageId: number) => Promise; + getImageExif: (imageId: number) => Promise; + loadTaggerModelStatus: () => Promise; + onSelectedImageReset: () => void; +} + +export function useLightboxMediaDetails({ + selectedImage, + taggerModelStatus, + getImageTags, + getImageExif, + loadTaggerModelStatus, + onSelectedImageReset, +}: UseLightboxMediaDetailsParams) { + const [imageTags, setImageTags] = useState([]); + const [imageExif, setImageExif] = useState(null); + const [tagInput, setTagInput] = useState(""); + const [tagAdding, setTagAdding] = useState(false); + const [tagsExpanded, setTagsExpanded] = useState(false); + const [taggingQueued, setTaggingQueued] = useState(false); + + const currentImageIdRef = useRef(null); + currentImageIdRef.current = selectedImage?.id ?? null; + + useEffect(() => { + setImageTags([]); + setImageExif(null); + setTagInput(""); + setTagsExpanded(false); + setTaggingQueued(false); + onSelectedImageReset(); + }, [onSelectedImageReset, selectedImage?.id]); + + useEffect(() => { + if (!selectedImage) return; + let cancelled = false; + void getImageTags(selectedImage.id) + .then((tags) => { if (!cancelled) setImageTags(tags); }) + .catch(() => { if (!cancelled) setImageTags([]); }); + return () => { cancelled = true; }; + }, [getImageTags, selectedImage?.ai_tagged_at, selectedImage?.id]); + + useEffect(() => { + if (!selectedImage || selectedImage.media_kind !== "image") { + setImageExif(null); + return; + } + let cancelled = false; + void getImageExif(selectedImage.id) + .then((exif) => { if (!cancelled) setImageExif(exif); }) + .catch(() => { if (!cancelled) setImageExif(null); }); + return () => { cancelled = true; }; + }, [getImageExif, selectedImage?.id, selectedImage?.media_kind]); + + useEffect(() => { + if (selectedImage?.media_kind !== "image" || taggerModelStatus !== null) return; + void loadTaggerModelStatus(); + }, [loadTaggerModelStatus, selectedImage?.media_kind, taggerModelStatus]); + + useEffect(() => { + if (selectedImage?.ai_tagged_at) setTaggingQueued(false); + }, [selectedImage?.ai_tagged_at]); + + return { + currentImageIdRef, + imageTags, + setImageTags, + imageExif, + tagInput, + setTagInput, + tagAdding, + setTagAdding, + tagsExpanded, + setTagsExpanded, + taggingQueued, + setTaggingQueued, + }; +} diff --git a/src/components/lightbox/useLightboxNavigation.ts b/src/components/lightbox/useLightboxNavigation.ts new file mode 100644 index 0000000..c99cf10 --- /dev/null +++ b/src/components/lightbox/useLightboxNavigation.ts @@ -0,0 +1,109 @@ +import { useCallback, useEffect } from "react"; +import { ImageRecord } from "../../store"; +import { ViewTransform } from "./types"; +import { zoomViewAt } from "./viewTransform"; + +interface UseLightboxNavigationParams { + selectedImage: ImageRecord | null; + images: ImageRecord[]; + currentIndex: number; + slideshowActive: boolean; + regionSelectMode: boolean; + closeImage: () => void; + exitRegionMode: () => void; + exitSlideshow: () => void; + goSlideshow: (direction: -1 | 1) => void; + showSlideshowControls: () => void; + setSlideshowPaused: React.Dispatch>; + openImage: (image: ImageRecord) => void; + setView: React.Dispatch>; + clampPan: (view: ViewTransform) => ViewTransform; +} + +export function useLightboxNavigation({ + selectedImage, + images, + currentIndex, + slideshowActive, + regionSelectMode, + closeImage, + exitRegionMode, + exitSlideshow, + goSlideshow, + showSlideshowControls, + setSlideshowPaused, + openImage, + setView, + clampPan, +}: UseLightboxNavigationParams) { + const goPrev = useCallback(() => { + if (currentIndex > 0) openImage(images[currentIndex - 1]); + }, [currentIndex, images, openImage]); + + const goNext = useCallback(() => { + if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); + }, [currentIndex, images, openImage]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (!selectedImage) return; + if (slideshowActive) { + if (event.key === "Escape") { + event.preventDefault(); + exitSlideshow(); + return; + } + if (event.key === " ") { + event.preventDefault(); + setSlideshowPaused((paused) => !paused); + showSlideshowControls(); + return; + } + if (event.key === "ArrowLeft" && !event.shiftKey) { + event.preventDefault(); + goSlideshow(-1); + return; + } + if (event.key === "ArrowRight" && !event.shiftKey) { + event.preventDefault(); + goSlideshow(1); + return; + } + return; + } + if (event.key === "Escape") { + if (regionSelectMode) { + exitRegionMode(); + } else { + closeImage(); + } + } + if (regionSelectMode) return; + if (event.key === "ArrowLeft" && !event.shiftKey) goPrev(); + if (event.key === "ArrowRight" && !event.shiftKey) goNext(); + if (event.key === "+" || event.key === "=") + setView((view) => clampPan(zoomViewAt(view, Math.min(3, view.zoom + 0.25), 0, 0))); + if (event.key === "-") + setView((view) => clampPan(zoomViewAt(view, Math.max(0.75, view.zoom - 0.25), 0, 0))); + }; + + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + clampPan, + closeImage, + exitRegionMode, + exitSlideshow, + goNext, + goPrev, + goSlideshow, + regionSelectMode, + selectedImage, + setSlideshowPaused, + setView, + showSlideshowControls, + slideshowActive, + ]); + + return { goPrev, goNext }; +} diff --git a/src/components/lightbox/useRegionSelection.ts b/src/components/lightbox/useRegionSelection.ts new file mode 100644 index 0000000..3051f46 --- /dev/null +++ b/src/components/lightbox/useRegionSelection.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { ImageRecord } from "../../store"; +import { DragRect, ViewTransform } from "./types"; +import { + clampPanToViewport, + MIN_SELECTION_FRACTION, + normaliseRect, + rectToNormalisedCrop, + zoomViewAt, +} from "./viewTransform"; + +interface UseRegionSelectionParams { + selectedImage: ImageRecord | null; + slideshowActive: boolean; + imageViewportRef: RefObject; + imgRef: RefObject; + view: ViewTransform; + setView: Dispatch>; + findSimilarByRegion: ( + imageId: number, + crop: { x: number; y: number; w: number; h: number }, + sourceFolderId: number | null, + ) => Promise; +} + +export function useRegionSelection({ + selectedImage, + slideshowActive, + imageViewportRef, + imgRef, + view, + setView, + findSimilarByRegion, +}: UseRegionSelectionParams) { + const [regionSelectMode, setRegionSelectMode] = useState(false); + const [isPanning, setIsPanning] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [dragRect, setDragRect] = useState(null); + const [regionSearching, setRegionSearching] = useState(false); + const lastPanPointRef = useRef({ x: 0, y: 0 }); + + const clampPan = useCallback( + (nextView: ViewTransform): ViewTransform => + clampPanToViewport(nextView, imgRef.current, imageViewportRef.current), + [imageViewportRef, imgRef], + ); + + const exitRegionMode = useCallback(() => { + setRegionSelectMode(false); + setIsDragging(false); + setDragRect(null); + }, []); + + useEffect(() => { + const viewport = imageViewportRef.current; + if (!viewport || !selectedImage || selectedImage.media_kind !== "image" || slideshowActive) return; + + const handleWheel = (event: WheelEvent) => { + if (regionSelectMode) return; + if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; + event.preventDefault(); + const bounds = viewport.getBoundingClientRect(); + const anchorX = event.clientX - (bounds.left + bounds.width / 2); + const anchorY = event.clientY - (bounds.top + bounds.height / 2); + setView((currentView) => { + const delta = event.deltaY < 0 ? 0.15 : -0.15; + const next = Math.min(4, Math.max(0.5, currentView.zoom + delta)); + return clampPan(zoomViewAt(currentView, next, anchorX, anchorY)); + }); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [clampPan, imageViewportRef, regionSelectMode, selectedImage, setView, slideshowActive]); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (!regionSelectMode) { + if (view.zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setIsPanning(true); + } + return; + } + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setIsDragging(true); + setDragRect({ + startX: event.clientX, + startY: event.clientY, + endX: event.clientX, + endY: event.clientY, + }); + }, + [regionSelectMode, selectedImage?.media_kind, view.zoom], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (isPanning) { + const dx = event.clientX - lastPanPointRef.current.x; + const dy = event.clientY - lastPanPointRef.current.y; + lastPanPointRef.current = { x: event.clientX, y: event.clientY }; + setView((currentView) => clampPan({ ...currentView, panX: currentView.panX + dx, panY: currentView.panY + dy })); + return; + } + if (!isDragging) return; + setDragRect((prev) => + prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null, + ); + }, + [clampPan, isDragging, isPanning, setView], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (isPanning) { + event.currentTarget.releasePointerCapture(event.pointerId); + setIsPanning(false); + return; + } + if (!isDragging || !dragRect || !selectedImage || !imgRef.current) { + setIsDragging(false); + return; + } + event.currentTarget.releasePointerCapture(event.pointerId); + + const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY }; + const crop = rectToNormalisedCrop(finalRect, imgRef.current); + + setIsDragging(false); + setDragRect(null); + + const containerBounds = imageViewportRef.current?.getBoundingClientRect(); + const containerSize = containerBounds + ? Math.min(containerBounds.width, containerBounds.height) + : 500; + const selW = Math.abs(finalRect.endX - finalRect.startX); + const selH = Math.abs(finalRect.endY - finalRect.startY); + if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) { + exitRegionMode(); + return; + } + + exitRegionMode(); + setRegionSearching(true); + + void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id) + .finally(() => setRegionSearching(false)); + }, + [dragRect, exitRegionMode, findSimilarByRegion, imageViewportRef, imgRef, isDragging, isPanning, selectedImage], + ); + + return { + regionSelectMode, + setRegionSelectMode, + isPanning, + regionSearching, + setRegionSearching, + exitRegionMode, + clampPan, + selectionOverlay: isDragging && dragRect ? normaliseRect(dragRect) : null, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/src/components/lightbox/useSlideshow.ts b/src/components/lightbox/useSlideshow.ts new file mode 100644 index 0000000..a8f8762 --- /dev/null +++ b/src/components/lightbox/useSlideshow.ts @@ -0,0 +1,276 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from "react"; +import type { Transition } from "framer-motion"; +import { ImageRecord, SlideshowOrder, SlideshowTransition } from "../../store"; +import { IDENTITY_VIEW } from "./viewTransform"; +import { ViewTransform } from "./types"; + +const SLIDESHOW_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; +const SLIDESHOW_CONTROLS_IDLE_MS = 5000; +const SLIDESHOW_LOAD_MORE_THRESHOLD = 3; + +export interface SlideshowMotion { + imageInitial: { opacity: number; filter: string }; + imageAnimate: { opacity: number; filter: string }; + imageExit: { opacity: number; filter: string }; + imageTransition: Transition; + contentInitial: { scale: number; x?: number; y?: number }; + contentAnimate: { scale: number; x?: number; y?: number }; + contentTransition: Transition; +} + +interface UseSlideshowParams { + rootRef: RefObject; + selectedImage: ImageRecord | null; + images: ImageRecord[]; + currentIndex: number; + loadedCount: number; + totalImages: number; + intervalSeconds: number; + order: SlideshowOrder; + transition: SlideshowTransition; + openImage: (image: ImageRecord) => void; + loadMoreImages: () => Promise; + exitRegionMode: () => void; + setView: Dispatch>; +} + +export function useSlideshow({ + rootRef, + selectedImage, + images, + currentIndex, + loadedCount, + totalImages, + intervalSeconds, + order, + transition, + openImage, + loadMoreImages, + exitRegionMode, + setView, +}: UseSlideshowParams) { + const [active, setActive] = useState(false); + const [paused, setPaused] = useState(false); + const [controlsVisible, setControlsVisible] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [motionStep, setMotionStep] = useState(0); + + const controlsIdleTimeoutRef = useRef(null); + const pointerRef = useRef<{ x: number; y: number } | null>(null); + const randomSeenRef = useRef>(new Set()); + + const slideshowImages = useMemo( + () => images.filter((image) => image.media_kind === "image"), + [images], + ); + const slideshowIndex = selectedImage + ? slideshowImages.findIndex((image) => image.id === selectedImage.id) + : -1; + const position = slideshowIndex >= 0 ? slideshowIndex + 1 : Math.min(slideshowImages.length, 1); + const canStart = slideshowImages.length > 0; + + const directions = [ + { x: -1, y: 0.45 }, + { x: 1, y: -0.45 }, + { x: -0.7, y: -0.55 }, + { x: 0.7, y: 0.55 }, + ]; + const direction = directions[motionStep % directions.length]; + const motionConfig: SlideshowMotion = { + imageInitial: { opacity: 0, filter: "blur(8px)" }, + imageAnimate: { opacity: 1, filter: "blur(0px)" }, + imageExit: { opacity: 0, filter: "blur(4px)" }, + imageTransition: { duration: 0.72, ease: SLIDESHOW_EASE }, + contentInitial: + transition === "gentle-motion" + ? { scale: 1.012, x: -8 * direction.x, y: 8 * direction.y } + : { scale: 1.012 }, + contentAnimate: + transition === "gentle-motion" + ? { scale: 1.026, x: 8 * direction.x, y: -8 * direction.y } + : { scale: 1 }, + contentTransition: + transition === "gentle-motion" + ? { duration: intervalSeconds + 0.75, ease: "linear" } + : { duration: 0.72, ease: SLIDESHOW_EASE }, + }; + + const showControls = useCallback(() => { + if (controlsIdleTimeoutRef.current !== null) { + window.clearTimeout(controlsIdleTimeoutRef.current); + controlsIdleTimeoutRef.current = null; + } + setControlsVisible(true); + if (active) { + controlsIdleTimeoutRef.current = window.setTimeout(() => { + setControlsVisible(false); + controlsIdleTimeoutRef.current = null; + }, SLIDESHOW_CONTROLS_IDLE_MS); + } + }, [active]); + + const exit = useCallback(() => { + setActive(false); + setPaused(false); + setControlsVisible(true); + if (document.fullscreenElement === rootRef.current) { + void document.exitFullscreen().catch(() => undefined); + } + }, [rootRef]); + + const go = useCallback( + (direction: -1 | 1, revealControls = true) => { + if (slideshowImages.length === 0) return; + if (slideshowImages.length === 1) { + openImage(slideshowImages[0]); + return; + } + + const currentSlideshowIndex = slideshowIndex >= 0 ? slideshowIndex : 0; + let nextIndex = + (currentSlideshowIndex + direction + slideshowImages.length) % slideshowImages.length; + if (order === "random") { + const currentId = slideshowImages[currentSlideshowIndex]?.id; + let candidates = slideshowImages.filter( + (image) => image.id !== currentId && !randomSeenRef.current.has(image.id), + ); + if (candidates.length === 0) { + randomSeenRef.current = new Set(currentId == null ? [] : [currentId]); + candidates = slideshowImages.filter((image) => image.id !== currentId); + } + const nextImage = candidates[Math.floor(Math.random() * candidates.length)]; + nextIndex = slideshowImages.findIndex((image) => image.id === nextImage.id); + randomSeenRef.current.add(nextImage.id); + } + setMotionStep((step) => step + 1); + openImage(slideshowImages[nextIndex]); + if (revealControls) { + showControls(); + } + }, + [openImage, order, showControls, slideshowImages, slideshowIndex], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + const previous = pointerRef.current; + const next = { x: event.clientX, y: event.clientY }; + pointerRef.current = next; + + if (!previous) { + if (!controlsVisible) { + showControls(); + } + return; + } + + if (Math.abs(previous.x - next.x) > 2 || Math.abs(previous.y - next.y) > 2) { + showControls(); + } + }, + [controlsVisible, showControls], + ); + + const start = useCallback(() => { + if (!selectedImage || slideshowImages.length === 0) return; + + const nextImage = + selectedImage.media_kind === "image" + ? selectedImage + : images.slice(Math.max(0, currentIndex + 1)).find((image) => image.media_kind === "image") ?? + slideshowImages[0]; + + if (nextImage.id !== selectedImage.id) { + openImage(nextImage); + } + + randomSeenRef.current = new Set([nextImage.id]); + setMotionStep(0); + exitRegionMode(); + setView(IDENTITY_VIEW); + setPaused(false); + setControlsVisible(true); + pointerRef.current = null; + setActive(true); + void rootRef.current?.requestFullscreen().catch(() => undefined); + }, [currentIndex, exitRegionMode, images, openImage, rootRef, selectedImage, setView, slideshowImages]); + + useEffect(() => { + if (selectedImage) return; + setActive(false); + setPaused(false); + setControlsVisible(true); + }, [selectedImage]); + + useEffect(() => { + const handleFullscreenChange = () => { + if (!active) return; + if (document.fullscreenElement !== rootRef.current) { + setActive(false); + setPaused(false); + setControlsVisible(true); + } + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, [active, rootRef]); + + useEffect(() => { + showControls(); + return () => { + if (controlsIdleTimeoutRef.current !== null) { + window.clearTimeout(controlsIdleTimeoutRef.current); + controlsIdleTimeoutRef.current = null; + } + }; + }, [active, paused, showControls]); + + useEffect(() => { + if (!active || paused || slideshowImages.length <= 1) return; + const timeout = window.setTimeout(() => { + go(1, false); + }, intervalSeconds * 1000); + return () => window.clearTimeout(timeout); + }, [active, go, intervalSeconds, paused, selectedImage?.id, slideshowImages.length]); + + useEffect(() => { + if ( + !active || + loadingMore || + loadedCount >= totalImages || + slideshowImages.length === 0 || + slideshowIndex < slideshowImages.length - SLIDESHOW_LOAD_MORE_THRESHOLD + ) { + return; + } + + setLoadingMore(true); + void loadMoreImages().finally(() => setLoadingMore(false)); + }, [ + active, + loadedCount, + loadMoreImages, + loadingMore, + slideshowImages.length, + slideshowIndex, + totalImages, + ]); + + return { + active, + paused, + setPaused, + controlsShown: controlsVisible, + loadingMore, + images: slideshowImages, + position, + canStart, + motionConfig, + start, + exit, + go, + showControls, + handlePointerMove, + }; +} diff --git a/src/components/lightbox/viewTransform.ts b/src/components/lightbox/viewTransform.ts new file mode 100644 index 0000000..86ae275 --- /dev/null +++ b/src/components/lightbox/viewTransform.ts @@ -0,0 +1,67 @@ +import { DragRect, NormalizedCrop, SelectionOverlay, ViewTransform } from "./types"; + +export const MIN_SELECTION_FRACTION = 0.02; +export const MIN_ZOOM = 0.5; +export const MAX_ZOOM = 4; +export const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 }; + +export function normaliseRect(r: DragRect): SelectionOverlay { + return { + left: Math.min(r.startX, r.endX), + top: Math.min(r.startY, r.endY), + width: Math.abs(r.endX - r.startX), + height: Math.abs(r.endY - r.startY), + }; +} + +export function rectToNormalisedCrop(rect: DragRect, imgEl: HTMLImageElement): NormalizedCrop | null { + const imgBounds = imgEl.getBoundingClientRect(); + if (imgBounds.width === 0 || imgBounds.height === 0) return null; + + const rawX = Math.min(rect.startX, rect.endX); + const rawY = Math.min(rect.startY, rect.endY); + const rawW = Math.abs(rect.endX - rect.startX); + const rawH = Math.abs(rect.endY - rect.startY); + + const clampedX = Math.max(rawX, imgBounds.left); + const clampedY = Math.max(rawY, imgBounds.top); + const clampedRight = Math.min(rawX + rawW, imgBounds.right); + const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom); + + const croppedW = clampedRight - clampedX; + const croppedH = clampedBottom - clampedY; + + if (croppedW <= 0 || croppedH <= 0) return null; + + return { + x: (clampedX - imgBounds.left) / imgBounds.width, + y: (clampedY - imgBounds.top) / imgBounds.height, + w: croppedW / imgBounds.width, + h: croppedH / imgBounds.height, + }; +} + +export function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform { + if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom }; + const ratio = newZoom / view.zoom; + return { + zoom: newZoom, + panX: anchorX - (anchorX - view.panX) * ratio, + panY: anchorY - (anchorY - view.panY) * ratio, + }; +} + +export function clampPanToViewport( + view: ViewTransform, + img: HTMLImageElement | null, + viewport: HTMLDivElement | null, +): ViewTransform { + if (!img || !viewport) return view; + const maxX = Math.max(0, (img.offsetWidth * view.zoom - viewport.clientWidth) / 2); + const maxY = Math.max(0, (img.offsetHeight * view.zoom - viewport.clientHeight) / 2); + return { + ...view, + panX: Math.min(maxX, Math.max(-maxX, view.panX)), + panY: Math.min(maxY, Math.max(-maxY, view.panY)), + }; +} From 2c0b928bf55a79cf10129bc4fab6be8058d66f5b Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 16:46:41 +0100 Subject: [PATCH 14/27] 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"; From 58ecb03070554fb81677a3052eb9b0c3a1d02d9f Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 18:09:41 +0100 Subject: [PATCH 15/27] refactor(explore): extract components from ExploreView into src/components/explore/ To de-godify \ExploreView.tsx\, the inline components \ClusterCloud\, \ExploreLoadingPanel\, \TagAtlas\, and \TagManageList\, along with shared constants in \layout.ts\, have been extracted into independent files. This improves modularity and maintainability. --- src/components/ExploreView.tsx | 1098 +---------------- src/components/explore/ClusterCloud.tsx | 238 ++++ .../explore/ExploreLoadingPanel.tsx | 41 + src/components/explore/TagAtlas.tsx | 366 ++++++ src/components/explore/TagManageList.tsx | 430 +++++++ src/components/explore/layout.ts | 34 + 6 files changed, 1115 insertions(+), 1092 deletions(-) create mode 100644 src/components/explore/ClusterCloud.tsx create mode 100644 src/components/explore/ExploreLoadingPanel.tsx create mode 100644 src/components/explore/TagAtlas.tsx create mode 100644 src/components/explore/TagManageList.tsx create mode 100644 src/components/explore/layout.ts diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index d69be2d..fe89011 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -1,1096 +1,10 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { motion, useReducedMotion } from "framer-motion"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { ExploreMode, ExploreTagEntry, RelatedTagEntry, VisualClusterEntry, useGalleryStore } from "../store"; +import { useEffect } from "react"; +import { useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { Dropdown } from "./menu"; -import { Tooltip } from "./Tooltip"; -import { mediaSrc } from "../lib/mediaSrc"; - -const ACCENTS = [ - "#60a5fa", - "#c084fc", - "#4ade80", - "#fbbf24", - "#f472b4", - "#2dd4bf", - "#fb923c", - "#a78bfa", - "#34d399", - "#f87171", -]; - -// Darker variants of each accent for the light theme — the bright originals are -// tuned for dark cards and wash out on the cream background. -const LIGHT_ACCENTS = [ - "#2563eb", - "#9333ea", - "#16a34a", - "#d97706", - "#db2777", - "#0d9488", - "#ea580c", - "#7c3aed", - "#059669", - "#dc2626", -]; - -const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); - -function seeded(n: number): number { - const x = Math.sin(n * 9301 + 49297) * 233280; - return x - Math.floor(x); -} - -interface PlacedNode { - entry: VisualClusterEntry; - index: number; - x: number; - y: number; - w: number; - h: number; - zIndex: number; - accent: string; - driftX: number; - driftY: number; - driftDuration: number; - rotateSeed: number; -} - -function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] { - if (!entries.length || containerW <= 0 || containerH <= 0) return []; - - const maxCount = Math.max(...entries.map((e) => e.count)); - const cx = containerW / 2; - const cy = containerH / 2; - const n = entries.length; - const ASPECT = 0.72; - const PAD = 18; - - // Card width scales with image count; the sub-linear exponent (< 1) widens the - // gap so the busiest clusters read as clearly larger and more prominent. - const rawWidth = (count: number) => { - const ratio = Math.max(count / maxCount, 0.05); - return 92 + Math.pow(ratio, 0.65) * 158; // ~92–250px before fit scaling - }; - - // Shrink every card uniformly when their padded footprint can't fit the - // canvas, so overlap resolution can actually pull them apart instead of - // settling into a pile. (0.6 leaves headroom for imperfect packing.) - const totalArea = entries.reduce((sum, e) => { - const w = rawWidth(e.count); - return sum + (w + PAD) * (w * ASPECT + PAD); - }, 0); - const usableArea = containerW * containerH * 0.6; - const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1; - - const spreadX = containerW * 0.44; - const spreadY = containerH * 0.4; - - // 1. Seed positions on a phyllotaxis spiral, sized by count. - const nodes: PlacedNode[] = entries.map((entry, i) => { - const w = rawWidth(entry.count) * fit; - const h = w * ASPECT; - const radialRatio = Math.sqrt((i + 0.5) / n); - const angle = i * GOLDEN_ANGLE; - - return { - entry, - index: i, - x: cx + Math.cos(angle) * radialRatio * spreadX, - y: cy + Math.sin(angle) * radialRatio * spreadY, - w, - h, - // Bigger (busier) clusters stack above smaller ones, so they stay - // clickable even if a sliver of overlap survives. - zIndex: Math.round(w), - accent: ACCENTS[i % ACCENTS.length], - driftX: (seeded(i + 11) - 0.5) * 18, - driftY: (seeded(i + 17) - 0.5) * 14, - driftDuration: 8 + seeded(i + 23) * 7, - rotateSeed: (seeded(i + 31) - 0.5) * 4, - }; - }); - - // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every - // pass so edge cards settle in-bounds instead of being shoved out and - // re-overlapping there. - const marginX = 14; - const marginY = 14; - for (let iter = 0; iter < 160; iter++) { - for (let a = 0; a < nodes.length; a++) { - const na = nodes[a]; - for (let b = a + 1; b < nodes.length; b++) { - const nb = nodes[b]; - const dx = nb.x - na.x; - const dy = nb.y - na.y; - const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); - const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); - if (overlapX <= 0 || overlapY <= 0) continue; - // Push along the smaller overlap axis (ternary yields ±1 so coincident - // cards still separate rather than stalling at a zero push). - if (overlapX < overlapY) { - const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); - nb.x += push; - na.x -= push; - } else { - const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); - nb.y += push; - na.y -= push; - } - } - } - for (const node of nodes) { - node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX); - node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY); - } - } - - return nodes; -} - -function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { - const src = mediaSrc(node.entry.thumbnail_path); - const { w, h, accent } = node; - const driftTransition = { - duration: node.driftDuration, - ease: "easeInOut" as const, - delay: seeded(node.index + 41) * 1.6, - repeat: 1, - repeatType: "reverse" as const, - }; - - return ( - - onOpen(node.entry.image_ids)} - > - {src ? ( - - ) : ( -
- )} -
- {/* Accent glow on hover */} -
-
-
-

Cluster

-

{node.entry.count.toLocaleString()}

-
- {/* Anchored to the card corner (not in the count's flex row) so a wide - count can't push it past the edge on small cards. */} - - Open - - - - ); -} - -interface AtlasNode { - entry: ExploreTagEntry; - index: number; - x: number; - y: number; - w: number; - h: number; - fontSize: number; - ratio: number; - accent: string; - driftX: number; - driftY: number; -} - -interface TagAnchor { - x: number; - y: number; -} - -const TAG_ATLAS_MAX_VISIBLE = 132; -const TAG_ATLAS_DENSE_THRESHOLD = 120; - -function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { - if (!entries.length || containerW <= 0 || containerH <= 0) return []; - - const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); - const logMin = Math.min(...logs); - const logRange = Math.max(...logs) - logMin || 1; - const cx = containerW / 2; - const cy = containerH * 0.48; - const spreadX = containerW * 0.47; - const spreadY = containerH * 0.46; - const accents = isLight ? LIGHT_ACCENTS : ACCENTS; - - const nodes = entries.map((entry, index) => { - const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; - const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; - const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; - const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; - const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); - const w = Math.min(containerW * maxWidthRatio, textWidth); - const h = fontSize * 1.18 + 14; - const radialRatio = Math.sqrt((index + 0.5) / entries.length); - const angle = index * GOLDEN_ANGLE; - return { - entry, - index, - x: cx + Math.cos(angle) * radialRatio * spreadX, - y: cy + Math.sin(angle) * radialRatio * spreadY, - w, - h, - fontSize, - ratio, - accent: accents[index % accents.length], - driftX: (seeded(index + 101) - 0.5) * 7, - driftY: (seeded(index + 113) - 0.5) * 6, - }; - }); - - const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; - const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; - const margin = 28; - for (let iter = 0; iter < 140; iter++) { - for (let a = 0; a < nodes.length; a++) { - const na = nodes[a]; - for (let b = a + 1; b < nodes.length; b++) { - const nb = nodes[b]; - const dx = nb.x - na.x; - const dy = nb.y - na.y; - const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); - const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); - if (overlapX <= 0 || overlapY <= 0) continue; - if (overlapX < overlapY) { - const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); - nb.x += push; - na.x -= push; - } else { - const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); - nb.y += push; - na.y -= push; - } - } - } - for (const node of nodes) { - node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); - node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); - if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { - node.y += (seeded(node.index + iter + 211) - 0.5) * 2; - } - if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { - node.x += (seeded(node.index + iter + 223) - 0.5) * 2; - } - } - } - - for (let iter = 0; iter < 5; iter++) { - const weighted = nodes.reduce( - (acc, node) => { - const weight = 1 + Math.pow(node.ratio, 1.35) * 9; - return { - x: acc.x + node.x * weight, - y: acc.y + node.y * weight, - weight: acc.weight + weight, - }; - }, - { x: 0, y: 0, weight: 0 }, - ); - const offsetX = containerW * 0.48 - weighted.x / weighted.weight; - const offsetY = containerH * 0.44 - weighted.y / weighted.weight; - if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; - for (const node of nodes) { - node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); - node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); - } - } - - return nodes; -} - -function TagAtlas({ - entries, - onSearch, - loadRelatedTags, -}: { - entries: ExploreTagEntry[]; - onSearch: (tag: string) => void; - loadRelatedTags: (tag: string) => Promise; -}) { - const theme = useGalleryStore((state) => state.theme); - const isLight = theme === "subtle-light"; - const reducedMotion = useReducedMotion(); - const canvasRef = useRef(null); - const buttonRefs = useRef(new Map()); - const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - const [activeTag, setActiveTag] = useState(null); - const [relatedTags, setRelatedTags] = useState([]); - const [anchors, setAnchors] = useState>({}); - const visibleEntries = useMemo( - () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), - [entries], - ); - - useLayoutEffect(() => { - const el = canvasRef.current; - if (!el) return; - const update = () => { - const r = el.getBoundingClientRect(); - setCanvasSize({ w: r.width, h: r.height }); - }; - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - useEffect(() => { - if (!activeTag) { - setRelatedTags([]); - return; - } - let cancelled = false; - void loadRelatedTags(activeTag).then((entries) => { - if (!cancelled) setRelatedTags(entries); - }); - return () => { - cancelled = true; - }; - }, [activeTag, loadRelatedTags]); - - const nodes = useMemo( - () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), - [visibleEntries, canvasSize.w, canvasSize.h, isLight], - ); - const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); - const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; - const minOpacity = isLight ? 0.62 : 0.42; - const visibleConnections = useMemo( - () => - activeNode - ? relatedTags - .map((related) => { - const node = nodeByTag.get(related.tag); - return node ? { node, related } : null; - }) - .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) - .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) - : [], - [activeNode, nodeByTag, relatedTags, visibleEntries.length], - ); - const activeAnchor = activeTag ? anchors[activeTag] : undefined; - const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); - const connectedByTag = useMemo( - () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), - [visibleConnections], - ); - - const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { - if (element) { - buttonRefs.current.set(tag, element); - } else { - buttonRefs.current.delete(tag); - } - }, []); - - const measureAnchors = useCallback(() => { - const canvas = canvasRef.current; - if (!canvas || !activeTag) { - setAnchors({}); - return; - } - - const canvasRect = canvas.getBoundingClientRect(); - const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; - const nextAnchors: Record = {}; - for (const tag of tagsToMeasure) { - const element = buttonRefs.current.get(tag); - if (!element) continue; - const rect = element.getBoundingClientRect(); - nextAnchors[tag] = { - x: rect.left - canvasRect.left + rect.width / 2, - y: rect.top - canvasRect.top + rect.height / 2, - }; - } - setAnchors(nextAnchors); - }, [activeTag, visibleConnections]); - - useLayoutEffect(() => { - if (!activeTag) { - setAnchors({}); - return; - } - - let firstFrame = 0; - let secondFrame = 0; - const settleTimer = window.setTimeout(measureAnchors, 180); - firstFrame = window.requestAnimationFrame(() => { - measureAnchors(); - secondFrame = window.requestAnimationFrame(measureAnchors); - }); - return () => { - window.cancelAnimationFrame(firstFrame); - window.cancelAnimationFrame(secondFrame); - window.clearTimeout(settleTimer); - }; - }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); - - return ( -
- - {nodes.map((node) => { - const isActive = activeTag === node.entry.tag; - const connectedRelated = connectedByTag.get(node.entry.tag); - const isRelated = connectedRelated !== undefined; - const dimmed = activeTag !== null && !isActive && !isRelated; - const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); - return ( - - - - ); - })} -
- ); -} - -function Spinner() { - return ( - - ); -} - -function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { - const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; - const subtitle = - mode === "visual" - ? "Grouping similar images into browsable clusters." - : "Collecting tags from the current library scope."; - - return ( -
-
-
- - {title} -
-

{subtitle}

-
- -
-
-
- ); -} - -// Separate component so its useLayoutEffect fires when the canvas is actually -// mounted — not at ExploreView mount time when the container may still be hidden -// behind a loading state. -function ClusterCloud({ - entries, - onOpen, -}: { - entries: VisualClusterEntry[]; - onOpen: (imageIds: number[]) => void; -}) { - const reducedMotion = useReducedMotion(); - const canvasRef = useRef(null); - const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); - - useLayoutEffect(() => { - const el = canvasRef.current; - if (!el) return; - const update = () => { - const r = el.getBoundingClientRect(); - setCanvasSize({ w: r.width, h: r.height }); - }; - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - const nodes = useMemo( - () => buildCloud(entries, canvasSize.w, canvasSize.h), - [entries, canvasSize.w, canvasSize.h], - ); - - return ( -
-
- {nodes.map((node) => ( - - ))} -
- ); -} - -type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; - -const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ - { value: "count_desc", label: "Most used" }, - { value: "count_asc", label: "Least used" }, - { value: "az", label: "A-Z" }, - { value: "za", label: "Z-A" }, -]; - -function AiSourceGlyph({ className = "" }: { className?: string }) { - return ( - - ); -} - -function RenameGlyph({ className = "" }: { className?: string }) { - return ( - - ); -} - -function DeleteGlyph({ className = "" }: { className?: string }) { - return ( - - ); -} - -// Compact management tile for a single tag. Rename doubles as merge when the new -// name already exists, and delete applies across the scoped tag set. -function TagManageTile({ - entry, - onSearch, - onRename, - onDelete, -}: { - entry: ExploreTagEntry; - onSearch: (tag: string) => void; - onRename: (from: string, to: string) => Promise; - onDelete: (tag: string) => Promise; -}) { - const [editing, setEditing] = useState(false); - const [value, setValue] = useState(entry.tag); - const [confirming, setConfirming] = useState(false); - const [busy, setBusy] = useState(false); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) { - setValue(entry.tag); - setTimeout(() => inputRef.current?.select(), 0); - } - }, [editing, entry.tag]); - - const commitRename = async () => { - const next = value.trim(); - if (!next || next === entry.tag) { - setEditing(false); - return; - } - setBusy(true); - try { - await onRename(entry.tag, next); - setEditing(false); - } finally { - setBusy(false); - } - }; - - const sourceLabel = entry.has_ai_source - ? entry.has_user_source - ? "Used by AI tags and user tags" - : "AI-generated tag" - : "User tag"; - - return ( -
-
- {editing ? ( - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { e.preventDefault(); void commitRename(); } - if (e.key === "Escape") setEditing(false); - }} - disabled={busy} - /> - ) : ( - - - - )} -
- {entry.has_ai_source ? ( - - - - {entry.count.toLocaleString()} - - - ) : ( - - {entry.count.toLocaleString()} - - )} -
-
- - {editing ? ( -
- - -
- ) : confirming ? ( -
- - -
- ) : ( -
- - - - - - -
- )} -
- ); -} - -function TagManageList({ - entries, - onSearch, - onRename, - onDelete, - onResetAiTags, - scopeLabel, -}: { - entries: ExploreTagEntry[]; - onSearch: (tag: string) => void; - onRename: (from: string, to: string) => Promise; - onDelete: (tag: string) => Promise; - onResetAiTags: () => Promise; - scopeLabel: string; -}) { - const scrollRef = useRef(null); - const measureRef = useRef(null); - const [query, setQuery] = useState(""); - const [sort, setSort] = useState("count_desc"); - const [columns, setColumns] = useState(3); - const [resetConfirming, setResetConfirming] = useState(false); - const [resetting, setResetting] = useState(false); - const [resetStatus, setResetStatus] = useState(null); - - useLayoutEffect(() => { - const el = measureRef.current; - if (!el) return; - const update = () => { - const width = el.getBoundingClientRect().width; - setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1); - }; - update(); - const ro = new ResizeObserver(update); - ro.observe(el); - return () => ro.disconnect(); - }, []); - - const filteredEntries = useMemo(() => { - const needle = query.trim().toLowerCase(); - const filtered = needle - ? entries.filter((entry) => entry.tag.toLowerCase().includes(needle)) - : entries; - return [...filtered].sort((left, right) => { - switch (sort) { - case "count_asc": - return left.count - right.count || left.tag.localeCompare(right.tag); - case "az": - return left.tag.localeCompare(right.tag); - case "za": - return right.tag.localeCompare(left.tag); - case "count_desc": - default: - return right.count - left.count || left.tag.localeCompare(right.tag); - } - }); - }, [entries, query, sort]); - - const rowCount = Math.ceil(filteredEntries.length / columns); - const rowVirtualizer = useVirtualizer({ - count: rowCount, - getScrollElement: () => scrollRef.current, - estimateSize: () => 54, - overscan: 7, - }); - const visibleItems = rowVirtualizer.getVirtualItems(); - const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); - - const runResetAiTags = async () => { - if (!resetConfirming) { - setResetConfirming(true); - setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`); - return; - } - - setResetting(true); - setResetStatus(null); - try { - const count = await onResetAiTags(); - setResetStatus( - count === 0 - ? "No AI tag data found for this scope." - : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, - ); - } catch (error) { - setResetStatus(String(error)); - } finally { - setResetting(false); - setResetConfirming(false); - } - }; - - return ( -
-
-
-
-
- {entries.length.toLocaleString()} tags - {totalUses.toLocaleString()} uses - {query.trim() ? ( - - {filteredEntries.length.toLocaleString()} matches - - ) : null} -
-

- Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope. -

- {resetStatus ?

{resetStatus}

: null} -
- -
- - {resetConfirming ? ( - - ) : null} -
- setQuery(event.target.value)} - placeholder="Filter tags" - /> - {query ? ( - - - - - - ) : null} -
- -
-
-
- -
-
- {filteredEntries.length === 0 ? ( -
- No tags match that filter. -
- ) : ( -
- {visibleItems.map((virtualRow) => { - const start = virtualRow.index * columns; - const rowEntries = filteredEntries.slice(start, start + columns); - return ( -
- {rowEntries.map((entry) => ( - - ))} -
- ); - })} -
- )} -
-
-
- ); -} +import { ClusterCloud } from "./explore/ClusterCloud"; +import { ExploreLoadingPanel } from "./explore/ExploreLoadingPanel"; +import { TagAtlas, TAG_ATLAS_MAX_VISIBLE } from "./explore/TagAtlas"; +import { TagManageList } from "./explore/TagManageList"; export function ExploreView() { const exploreMode = useGalleryStore((state) => state.exploreMode); diff --git a/src/components/explore/ClusterCloud.tsx b/src/components/explore/ClusterCloud.tsx new file mode 100644 index 0000000..2fff6f0 --- /dev/null +++ b/src/components/explore/ClusterCloud.tsx @@ -0,0 +1,238 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import type { VisualClusterEntry } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { Tooltip } from "../Tooltip"; +import { ACCENTS, GOLDEN_ANGLE, seeded } from "./layout"; + +interface PlacedNode { + entry: VisualClusterEntry; + index: number; + x: number; + y: number; + w: number; + h: number; + zIndex: number; + accent: string; + driftX: number; + driftY: number; + driftDuration: number; + rotateSeed: number; +} + +function buildCloud(entries: VisualClusterEntry[], containerW: number, containerH: number): PlacedNode[] { + if (!entries.length || containerW <= 0 || containerH <= 0) return []; + + const maxCount = Math.max(...entries.map((e) => e.count)); + const cx = containerW / 2; + const cy = containerH / 2; + const n = entries.length; + const ASPECT = 0.72; + const PAD = 18; + + // Card width scales with image count; the sub-linear exponent (< 1) widens the + // gap so the busiest clusters read as clearly larger and more prominent. + const rawWidth = (count: number) => { + const ratio = Math.max(count / maxCount, 0.05); + return 92 + Math.pow(ratio, 0.65) * 158; // ~92–250px before fit scaling + }; + + // Shrink every card uniformly when their padded footprint can't fit the + // canvas, so overlap resolution can actually pull them apart instead of + // settling into a pile. (0.6 leaves headroom for imperfect packing.) + const totalArea = entries.reduce((sum, e) => { + const w = rawWidth(e.count); + return sum + (w + PAD) * (w * ASPECT + PAD); + }, 0); + const usableArea = containerW * containerH * 0.6; + const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1; + + const spreadX = containerW * 0.44; + const spreadY = containerH * 0.4; + + // 1. Seed positions on a phyllotaxis spiral, sized by count. + const nodes: PlacedNode[] = entries.map((entry, i) => { + const w = rawWidth(entry.count) * fit; + const h = w * ASPECT; + const radialRatio = Math.sqrt((i + 0.5) / n); + const angle = i * GOLDEN_ANGLE; + + return { + entry, + index: i, + x: cx + Math.cos(angle) * radialRatio * spreadX, + y: cy + Math.sin(angle) * radialRatio * spreadY, + w, + h, + // Bigger (busier) clusters stack above smaller ones, so they stay + // clickable even if a sliver of overlap survives. + zIndex: Math.round(w), + accent: ACCENTS[i % ACCENTS.length], + driftX: (seeded(i + 11) - 0.5) * 18, + driftY: (seeded(i + 17) - 0.5) * 14, + driftDuration: 8 + seeded(i + 23) * 7, + rotateSeed: (seeded(i + 31) - 0.5) * 4, + }; + }); + + // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every + // pass so edge cards settle in-bounds instead of being shoved out and + // re-overlapping there. + const marginX = 14; + const marginY = 14; + for (let iter = 0; iter < 160; iter++) { + for (let a = 0; a < nodes.length; a++) { + const na = nodes[a]; + for (let b = a + 1; b < nodes.length; b++) { + const nb = nodes[b]; + const dx = nb.x - na.x; + const dy = nb.y - na.y; + const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); + const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); + if (overlapX <= 0 || overlapY <= 0) continue; + // Push along the smaller overlap axis (ternary yields ±1 so coincident + // cards still separate rather than stalling at a zero push). + if (overlapX < overlapY) { + const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); + nb.x += push; + na.x -= push; + } else { + const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); + nb.y += push; + na.y -= push; + } + } + } + for (const node of nodes) { + node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX); + node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY); + } + } + + return nodes; +} + +function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) { + const src = mediaSrc(node.entry.thumbnail_path); + const { w, h, accent } = node; + const driftTransition = { + duration: node.driftDuration, + ease: "easeInOut" as const, + delay: seeded(node.index + 41) * 1.6, + repeat: 1, + repeatType: "reverse" as const, + }; + + return ( + + onOpen(node.entry.image_ids)} + > + {src ? ( + + ) : ( +
+ )} +
+ {/* Accent glow on hover */} +
+
+
+

Cluster

+

{node.entry.count.toLocaleString()}

+
+ {/* Anchored to the card corner (not in the count's flex row) so a wide + count can't push it past the edge on small cards. */} + + Open + + + + ); +} + +// Separate component so its useLayoutEffect fires when the canvas is actually +// mounted, not at ExploreView mount time when the container may still be hidden +// behind a loading state. +export function ClusterCloud({ + entries, + onOpen, +}: { + entries: VisualClusterEntry[]; + onOpen: (imageIds: number[]) => void; +}) { + const reducedMotion = useReducedMotion(); + const canvasRef = useRef(null); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + + useLayoutEffect(() => { + const el = canvasRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setCanvasSize({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const nodes = useMemo( + () => buildCloud(entries, canvasSize.w, canvasSize.h), + [entries, canvasSize.w, canvasSize.h], + ); + + return ( +
+
+ {nodes.map((node) => ( + + ))} +
+ ); +} diff --git a/src/components/explore/ExploreLoadingPanel.tsx b/src/components/explore/ExploreLoadingPanel.tsx new file mode 100644 index 0000000..f33b2b3 --- /dev/null +++ b/src/components/explore/ExploreLoadingPanel.tsx @@ -0,0 +1,41 @@ +import { motion } from "framer-motion"; +import type { ExploreMode } from "../../store"; + +function Spinner() { + return ( + + ); +} + +export function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { + const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; + const subtitle = + mode === "visual" + ? "Grouping similar images into browsable clusters." + : "Collecting tags from the current library scope."; + + return ( +
+
+
+ + {title} +
+

{subtitle}

+
+ +
+
+
+ ); +} + + diff --git a/src/components/explore/TagAtlas.tsx b/src/components/explore/TagAtlas.tsx new file mode 100644 index 0000000..62d196f --- /dev/null +++ b/src/components/explore/TagAtlas.tsx @@ -0,0 +1,366 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import type { ExploreTagEntry, RelatedTagEntry } from "../../store"; +import { useGalleryStore } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { ACCENTS, GOLDEN_ANGLE, LIGHT_ACCENTS, seeded } from "./layout"; + +interface AtlasNode { + entry: ExploreTagEntry; + index: number; + x: number; + y: number; + w: number; + h: number; + fontSize: number; + ratio: number; + accent: string; + driftX: number; + driftY: number; +} + +interface TagAnchor { + x: number; + y: number; +} + +export const TAG_ATLAS_MAX_VISIBLE = 132; +const TAG_ATLAS_DENSE_THRESHOLD = 120; + +function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { + if (!entries.length || containerW <= 0 || containerH <= 0) return []; + + const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); + const logMin = Math.min(...logs); + const logRange = Math.max(...logs) - logMin || 1; + const cx = containerW / 2; + const cy = containerH * 0.48; + const spreadX = containerW * 0.47; + const spreadY = containerH * 0.46; + const accents = isLight ? LIGHT_ACCENTS : ACCENTS; + + const nodes = entries.map((entry, index) => { + const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; + const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; + const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; + const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; + const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); + const w = Math.min(containerW * maxWidthRatio, textWidth); + const h = fontSize * 1.18 + 14; + const radialRatio = Math.sqrt((index + 0.5) / entries.length); + const angle = index * GOLDEN_ANGLE; + return { + entry, + index, + x: cx + Math.cos(angle) * radialRatio * spreadX, + y: cy + Math.sin(angle) * radialRatio * spreadY, + w, + h, + fontSize, + ratio, + accent: accents[index % accents.length], + driftX: (seeded(index + 101) - 0.5) * 7, + driftY: (seeded(index + 113) - 0.5) * 6, + }; + }); + + const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; + const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; + const margin = 28; + for (let iter = 0; iter < 140; iter++) { + for (let a = 0; a < nodes.length; a++) { + const na = nodes[a]; + for (let b = a + 1; b < nodes.length; b++) { + const nb = nodes[b]; + const dx = nb.x - na.x; + const dy = nb.y - na.y; + const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); + const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); + if (overlapX <= 0 || overlapY <= 0) continue; + if (overlapX < overlapY) { + const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); + nb.x += push; + na.x -= push; + } else { + const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); + nb.y += push; + na.y -= push; + } + } + } + for (const node of nodes) { + node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); + if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { + node.y += (seeded(node.index + iter + 211) - 0.5) * 2; + } + if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { + node.x += (seeded(node.index + iter + 223) - 0.5) * 2; + } + } + } + + for (let iter = 0; iter < 5; iter++) { + const weighted = nodes.reduce( + (acc, node) => { + const weight = 1 + Math.pow(node.ratio, 1.35) * 9; + return { + x: acc.x + node.x * weight, + y: acc.y + node.y * weight, + weight: acc.weight + weight, + }; + }, + { x: 0, y: 0, weight: 0 }, + ); + const offsetX = containerW * 0.48 - weighted.x / weighted.weight; + const offsetY = containerH * 0.44 - weighted.y / weighted.weight; + if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; + for (const node of nodes) { + node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); + } + } + + return nodes; +} + +export function TagAtlas({ + entries, + onSearch, + loadRelatedTags, +}: { + entries: ExploreTagEntry[]; + onSearch: (tag: string) => void; + loadRelatedTags: (tag: string) => Promise; +}) { + const theme = useGalleryStore((state) => state.theme); + const isLight = theme === "subtle-light"; + const reducedMotion = useReducedMotion(); + const canvasRef = useRef(null); + const buttonRefs = useRef(new Map()); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [activeTag, setActiveTag] = useState(null); + const [relatedTags, setRelatedTags] = useState([]); + const [anchors, setAnchors] = useState>({}); + const visibleEntries = useMemo( + () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), + [entries], + ); + + useLayoutEffect(() => { + const el = canvasRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setCanvasSize({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!activeTag) { + setRelatedTags([]); + return; + } + let cancelled = false; + void loadRelatedTags(activeTag).then((entries) => { + if (!cancelled) setRelatedTags(entries); + }); + return () => { + cancelled = true; + }; + }, [activeTag, loadRelatedTags]); + + const nodes = useMemo( + () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), + [visibleEntries, canvasSize.w, canvasSize.h, isLight], + ); + const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); + const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; + const minOpacity = isLight ? 0.62 : 0.42; + const visibleConnections = useMemo( + () => + activeNode + ? relatedTags + .map((related) => { + const node = nodeByTag.get(related.tag); + return node ? { node, related } : null; + }) + .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) + .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) + : [], + [activeNode, nodeByTag, relatedTags, visibleEntries.length], + ); + const activeAnchor = activeTag ? anchors[activeTag] : undefined; + const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); + const connectedByTag = useMemo( + () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), + [visibleConnections], + ); + + const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { + if (element) { + buttonRefs.current.set(tag, element); + } else { + buttonRefs.current.delete(tag); + } + }, []); + + const measureAnchors = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !activeTag) { + setAnchors({}); + return; + } + + const canvasRect = canvas.getBoundingClientRect(); + const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; + const nextAnchors: Record = {}; + for (const tag of tagsToMeasure) { + const element = buttonRefs.current.get(tag); + if (!element) continue; + const rect = element.getBoundingClientRect(); + nextAnchors[tag] = { + x: rect.left - canvasRect.left + rect.width / 2, + y: rect.top - canvasRect.top + rect.height / 2, + }; + } + setAnchors(nextAnchors); + }, [activeTag, visibleConnections]); + + useLayoutEffect(() => { + if (!activeTag) { + setAnchors({}); + return; + } + + let firstFrame = 0; + let secondFrame = 0; + const settleTimer = window.setTimeout(measureAnchors, 180); + firstFrame = window.requestAnimationFrame(() => { + measureAnchors(); + secondFrame = window.requestAnimationFrame(measureAnchors); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + window.clearTimeout(settleTimer); + }; + }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); + + return ( +
+ + {nodes.map((node) => { + const isActive = activeTag === node.entry.tag; + const connectedRelated = connectedByTag.get(node.entry.tag); + const isRelated = connectedRelated !== undefined; + const dimmed = activeTag !== null && !isActive && !isRelated; + const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); + return ( + + + + ); + })} +
+ ); +} + + diff --git a/src/components/explore/TagManageList.tsx b/src/components/explore/TagManageList.tsx new file mode 100644 index 0000000..05343ea --- /dev/null +++ b/src/components/explore/TagManageList.tsx @@ -0,0 +1,430 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import type { ExploreTagEntry } from "../../store"; +import { Dropdown } from "../menu"; +import { Tooltip } from "../Tooltip"; + +type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; + +const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ + { value: "count_desc", label: "Most used" }, + { value: "count_asc", label: "Least used" }, + { value: "az", label: "A-Z" }, + { value: "za", label: "Z-A" }, +]; + +function AiSourceGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function RenameGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function DeleteGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +// Compact management tile for a single tag. Rename doubles as merge when the new +// name already exists, and delete applies across the scoped tag set. +function TagManageTile({ + entry, + onSearch, + onRename, + onDelete, +}: { + entry: ExploreTagEntry; + onSearch: (tag: string) => void; + onRename: (from: string, to: string) => Promise; + onDelete: (tag: string) => Promise; +}) { + const [editing, setEditing] = useState(false); + const [value, setValue] = useState(entry.tag); + const [confirming, setConfirming] = useState(false); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) { + setValue(entry.tag); + setTimeout(() => inputRef.current?.select(), 0); + } + }, [editing, entry.tag]); + + const commitRename = async () => { + const next = value.trim(); + if (!next || next === entry.tag) { + setEditing(false); + return; + } + setBusy(true); + try { + await onRename(entry.tag, next); + setEditing(false); + } finally { + setBusy(false); + } + }; + + const sourceLabel = entry.has_ai_source + ? entry.has_user_source + ? "Used by AI tags and user tags" + : "AI-generated tag" + : "User tag"; + + return ( +
+
+ {editing ? ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { e.preventDefault(); void commitRename(); } + if (e.key === "Escape") setEditing(false); + }} + disabled={busy} + /> + ) : ( + + + + )} +
+ {entry.has_ai_source ? ( + + + + {entry.count.toLocaleString()} + + + ) : ( + + {entry.count.toLocaleString()} + + )} +
+
+ + {editing ? ( +
+ + +
+ ) : confirming ? ( +
+ + +
+ ) : ( +
+ + + + + + +
+ )} +
+ ); +} + +export function TagManageList({ + entries, + onSearch, + onRename, + onDelete, + onResetAiTags, + scopeLabel, +}: { + entries: ExploreTagEntry[]; + onSearch: (tag: string) => void; + onRename: (from: string, to: string) => Promise; + onDelete: (tag: string) => Promise; + onResetAiTags: () => Promise; + scopeLabel: string; +}) { + const scrollRef = useRef(null); + const measureRef = useRef(null); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("count_desc"); + const [columns, setColumns] = useState(3); + const [resetConfirming, setResetConfirming] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetStatus, setResetStatus] = useState(null); + + useLayoutEffect(() => { + const el = measureRef.current; + if (!el) return; + const update = () => { + const width = el.getBoundingClientRect().width; + setColumns(width >= 1160 ? 4 : width >= 780 ? 3 : width >= 520 ? 2 : 1); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const filteredEntries = useMemo(() => { + const needle = query.trim().toLowerCase(); + const filtered = needle + ? entries.filter((entry) => entry.tag.toLowerCase().includes(needle)) + : entries; + return [...filtered].sort((left, right) => { + switch (sort) { + case "count_asc": + return left.count - right.count || left.tag.localeCompare(right.tag); + case "az": + return left.tag.localeCompare(right.tag); + case "za": + return right.tag.localeCompare(left.tag); + case "count_desc": + default: + return right.count - left.count || left.tag.localeCompare(right.tag); + } + }); + }, [entries, query, sort]); + + const rowCount = Math.ceil(filteredEntries.length / columns); + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => scrollRef.current, + estimateSize: () => 54, + overscan: 7, + }); + const visibleItems = rowVirtualizer.getVirtualItems(); + const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); + + const runResetAiTags = async () => { + if (!resetConfirming) { + setResetConfirming(true); + setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`); + return; + } + + setResetting(true); + setResetStatus(null); + try { + const count = await onResetAiTags(); + setResetStatus( + count === 0 + ? "No AI tag data found for this scope." + : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, + ); + } catch (error) { + setResetStatus(String(error)); + } finally { + setResetting(false); + setResetConfirming(false); + } + }; + + return ( +
+
+
+
+
+ {entries.length.toLocaleString()} tags + {totalUses.toLocaleString()} uses + {query.trim() ? ( + + {filteredEntries.length.toLocaleString()} matches + + ) : null} +
+

+ Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope. +

+ {resetStatus ?

{resetStatus}

: null} +
+ +
+ + {resetConfirming ? ( + + ) : null} +
+ setQuery(event.target.value)} + placeholder="Filter tags" + /> + {query ? ( + + + + + + ) : null} +
+ +
+
+
+ +
+
+ {filteredEntries.length === 0 ? ( +
+ No tags match that filter. +
+ ) : ( +
+ {visibleItems.map((virtualRow) => { + const start = virtualRow.index * columns; + const rowEntries = filteredEntries.slice(start, start + columns); + return ( +
+ {rowEntries.map((entry) => ( + + ))} +
+ ); + })} +
+ )} +
+
+
+ ); +} + + diff --git a/src/components/explore/layout.ts b/src/components/explore/layout.ts new file mode 100644 index 0000000..913a743 --- /dev/null +++ b/src/components/explore/layout.ts @@ -0,0 +1,34 @@ +export const ACCENTS = [ + "#60a5fa", + "#c084fc", + "#4ade80", + "#fbbf24", + "#f472b4", + "#2dd4bf", + "#fb923c", + "#a78bfa", + "#34d399", + "#f87171", +]; + +// Darker variants of each accent for the light theme -- the bright originals are +// tuned for dark cards and wash out on the cream background. +export const LIGHT_ACCENTS = [ + "#2563eb", + "#9333ea", + "#16a34a", + "#d97706", + "#db2777", + "#0d9488", + "#ea580c", + "#7c3aed", + "#059669", + "#dc2626", +]; + +export const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); + +export function seeded(n: number): number { + const x = Math.sin(n * 9301 + 49297) * 233280; + return x - Math.floor(x); +} From 1074c875a36b80b601277d117adf99551df989dd Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 18:30:25 +0100 Subject: [PATCH 16/27] fix(menus): prevent menu label text from being selectable --- src/components/menu/Menu.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/menu/Menu.tsx b/src/components/menu/Menu.tsx index 4143a4b..ec709e9 100644 --- a/src/components/menu/Menu.tsx +++ b/src/components/menu/Menu.tsx @@ -123,12 +123,17 @@ export function MenuItem({ } export function MenuSeparator() { - return
; + return
; } export function MenuLabel({ children }: { children: ReactNode }) { return ( -
{children}
+
{children}
); } From 2901425f42f783d665da1a4dea4138522ed867b7 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 18:57:36 +0100 Subject: [PATCH 17/27] refactor(sidebar): modularize Sidebar Break down the monolithic Sidebar component into smaller, maintainable modules to improve readability and separation of concerns. - Extract `NavItem`, `FolderItem`, and `AlbumItem` into dedicated UI components. - Modularize domain logic by introducing `LibrarySection` and `AlbumSection`. - Abstract complex drag-and-drop and ordering state into custom `useFolderOrdering` and `useAlbumOrdering` hooks. - Move shared constants and types to a dedicated `types.ts` file. --- src/components/Sidebar.tsx | 855 +------------------- src/components/sidebar/AlbumItem.tsx | 176 ++++ src/components/sidebar/AlbumSection.tsx | 221 +++++ src/components/sidebar/FolderItem.tsx | 227 ++++++ src/components/sidebar/LibrarySection.tsx | 81 ++ src/components/sidebar/NavItem.tsx | 27 + src/components/sidebar/types.ts | 3 + src/components/sidebar/useAlbumOrdering.ts | 54 ++ src/components/sidebar/useFolderOrdering.ts | 130 +++ 9 files changed, 928 insertions(+), 846 deletions(-) create mode 100644 src/components/sidebar/AlbumItem.tsx create mode 100644 src/components/sidebar/AlbumSection.tsx create mode 100644 src/components/sidebar/FolderItem.tsx create mode 100644 src/components/sidebar/LibrarySection.tsx create mode 100644 src/components/sidebar/NavItem.tsx create mode 100644 src/components/sidebar/types.ts create mode 100644 src/components/sidebar/useAlbumOrdering.ts create mode 100644 src/components/sidebar/useFolderOrdering.ts diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 465860a..6af9414 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,656 +1,24 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Reorder, useDragControls } from "framer-motion"; -import { open } from "@tauri-apps/plugin-dialog"; -import { useGalleryStore, Folder, Album, IndexProgress } from "../store"; -import { ContextMenu, Dropdown, MenuItem, MenuSeparator } from "./menu"; -import { InlineConfirm } from "./InlineConfirm"; -import { InlineRename } from "./InlineRename"; -import { mediaSrc } from "../lib/mediaSrc"; +import { useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; -import { CheckIcon, CloseIcon, FolderIcon, PhotoIcon, PlusIcon } from "./icons"; - -type LibrarySort = "az" | "za" | "custom"; -const LIBRARY_SORT_KEY = "phokus-library-sort"; - -function NavItem({ - label, - iconPath, - active, - onClick, -}: { - label: string; - iconPath: string; - active: boolean; - onClick: () => void; -}) { - return ( -
- - - - {label} -
- ); -} - -function FolderItem({ - folder, - selected, - progress, - customOrdering, - dragging, - onDragStart, - onDragEnd, - onKeyboardMove, -}: { - folder: Folder; - selected: boolean; - progress: IndexProgress | undefined; - customOrdering: boolean; - dragging: boolean; - onDragStart: (pointerY: number) => void; - onDragEnd: () => void; - onKeyboardMove: (direction: -1 | 1) => void; -}) { - const dragControls = useDragControls(); - const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); - const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); - const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused); - const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]); - const isMuted = mutedFolderIds.includes(folder.id); - // "Fully paused" only when every worker for this folder is paused. - const isPausedAll = !!folderWorkers && - folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging; - const isIndexing = progress && !progress.done; - const isMissing = !!folder.scan_error && !isIndexing; - - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const [renaming, setRenaming] = useState(false); - const [confirmingRemoval, setConfirmingRemoval] = useState(false); - - const handleContextMenu = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }; - - const handleLocateFolder = async () => { - const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); - if (picked && typeof picked === "string") { - await updateFolderPath(folder.id, picked); - } - }; - - return ( - -
!renaming && selectFolder(folder.id)} - onContextMenu={handleContextMenu} - > - {customOrdering ? ( - - - - ) : null} - {isMissing ? ( - - - - - - ) : ( - - )} - -
- {renaming ? ( - renameFolder(folder.id, next)} - onClose={() => setRenaming(false)} - /> - ) : ( -
- {folder.name} -
- )} - {isIndexing ? ( - <> -
{progress.indexed}/{progress.total}
-
-
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} - /> -
- - ) : ( -
{folder.image_count.toLocaleString()}
- )} -
- - {/* Hover action buttons */} - {!renaming && ( - confirmingRemoval ? ( - { void removeFolder(folder.id); setConfirmingRemoval(false); }} - onCancel={() => setConfirmingRemoval(false)} - /> - ) : ( -
- - - - - - -
- ) - )} -
- - {isMissing && ( -
-

Folder not found

-

- This folder may have been moved or renamed. Locate it to resume, or remove it from the app. -

-
- - -
-
- )} - - {contextMenu && ( - setContextMenu(null)}> - void reindexFolder(folder.id)} /> - setRenaming(true)} /> - setAllWorkersPaused(folder.id, !isPausedAll)} - /> - toggleMutedFolder(folder.id)} - /> - {folder.scan_error ? void handleLocateFolder()} /> : null} - - setConfirmingRemoval(true)} /> - - )} - - ); -} - -function AlbumItem({ - album, - manageMode = false, - selectedForManage = false, - onToggleManage, - reorderable = false, - onDragStart, - onDragEnd, -}: { - album: Album; - manageMode?: boolean; - selectedForManage?: boolean; - onToggleManage?: () => void; - reorderable?: boolean; - onDragStart?: () => void; - onDragEnd?: () => void; -}) { - const dragControls = useDragControls(); - const viewAlbum = useGalleryStore((state) => state.viewAlbum); - const renameAlbum = useGalleryStore((state) => state.renameAlbum); - const deleteAlbum = useGalleryStore((state) => state.deleteAlbum); - const activeView = useGalleryStore((state) => state.activeView); - const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); - const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id; - - const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); - const [renaming, setRenaming] = useState(false); - const [confirmingRemoval, setConfirmingRemoval] = useState(false); - - const cover = mediaSrc(album.cover_thumbnail_path); - - const row = ( -
{ - if (manageMode) { - onToggleManage?.(); - } else if (!renaming) { - viewAlbum(album.id); - } - }} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) return; - if (renaming || (e.key !== "Enter" && e.key !== " ")) return; - e.preventDefault(); - if (manageMode) { - onToggleManage?.(); - } else { - viewAlbum(album.id); - } - }} - onContextMenu={(e) => { - if (manageMode) return; - e.preventDefault(); - e.stopPropagation(); - setMenu({ x: e.clientX, y: e.clientY }); - }} - > - {/* Manage-mode selection checkbox */} - {manageMode ? ( -
- -
- ) : null} - - {/* Drag handle — hover-revealed, reorders albums */} - {reorderable ? ( - - - - ) : null} - - {/* Cover thumbnail — distinguishes albums from folder rows */} -
- {cover ? ( - - ) : ( -
- -
- )} -
- -
- {renaming ? ( - renameAlbum(album.id, next)} - onClose={() => setRenaming(false)} - /> - ) : ( -
- {album.name} -
- )} -
{album.image_count.toLocaleString()}
-
- - {!renaming && confirmingRemoval ? ( - { void deleteAlbum(album.id); setConfirmingRemoval(false); }} - onCancel={() => setConfirmingRemoval(false)} - /> - ) : null} - - {menu ? ( - setMenu(null)}> - setRenaming(true)} /> - - setConfirmingRemoval(true)} /> - - ) : null} -
- ); - - if (reorderable) { - return ( - - {row} - - ); - } - return row; -} +import { PlusIcon } from "./icons"; +import { AlbumSection } from "./sidebar/AlbumSection"; +import { LibrarySection } from "./sidebar/LibrarySection"; +import { NavItem } from "./sidebar/NavItem"; export function Sidebar() { - const folders = useGalleryStore((state) => state.folders); - const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); - const indexingProgress = useGalleryStore((state) => state.indexingProgress); + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectFolder = useGalleryStore((state) => state.selectFolder); const activeView = useGalleryStore((state) => state.activeView); const setView = useGalleryStore((state) => state.setView); - const reorderFolders = useGalleryStore((state) => state.reorderFolders); - const albums = useGalleryStore((state) => state.albums); - const createAlbum = useGalleryStore((state) => state.createAlbum); - const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); - const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); - const [creatingAlbum, setCreatingAlbum] = useState(false); - const [createAlbumPending, setCreateAlbumPending] = useState(false); - const [newAlbumName, setNewAlbumName] = useState(""); - const newAlbumInputRef = useRef(null); - const [manageAlbums, setManageAlbums] = useState(false); - const [manageSelectedIds, setManageSelectedIds] = useState>(new Set()); - const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false); - const [orderedAlbums, setOrderedAlbums] = useState(albums); - const orderedAlbumsRef = useRef(albums); - const [draggingAlbum, setDraggingAlbum] = useState(false); - - // Keep the local drag order in sync with the store except mid-drag, so a - // background album refresh doesn't yank the row out from under the pointer. - useEffect(() => { - if (draggingAlbum) return; - setOrderedAlbums(albums); - orderedAlbumsRef.current = albums; - }, [albums, draggingAlbum]); - - const handleAlbumReorder = (ids: number[]) => { - const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album])); - const next = ids - .map((id) => byId.get(id)) - .filter((album): album is Album => album !== undefined); - orderedAlbumsRef.current = next; - setOrderedAlbums(next); - }; - - const finishAlbumReorder = () => { - setDraggingAlbum(false); - const nextIds = orderedAlbumsRef.current.map((album) => album.id); - // Read live store order (not the render-time closure) in case albums changed. - const currentIds = useGalleryStore.getState().albums.map((album) => album.id); - const snapshotIds = albums.map((album) => album.id); - if ( - snapshotIds.length !== currentIds.length || - snapshotIds.some((id, index) => id !== currentIds[index]) - ) { - orderedAlbumsRef.current = useGalleryStore.getState().albums; - setOrderedAlbums(orderedAlbumsRef.current); - return; - } - if ( - nextIds.length !== currentIds.length || - nextIds.some((id, index) => id !== currentIds[index]) - ) { - void reorderAlbums(nextIds); - } - }; - const [librarySort, setLibrarySortState] = useState(() => { - const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); - return saved === "za" || saved === "custom" ? saved : "az"; - }); - const [customFolders, setCustomFolders] = useState(folders); - const [draggedFolderId, setDraggedFolderId] = useState(null); - const folderListRef = useRef(null); - const customFoldersRef = useRef(folders); - const pointerYRef = useRef(0); - const autoScrollFrameRef = useRef(null); - const keyboardPersistRef = useRef | null>(null); - - useEffect( - () => () => { - if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); - }, - [], - ); - - useEffect(() => { - if (draggedFolderId !== null) return; - setCustomFolders(folders); - customFoldersRef.current = folders; - }, [folders, draggedFolderId]); - - useEffect(() => { - if (draggedFolderId === null) return; - - const handlePointerMove = (event: PointerEvent) => { - pointerYRef.current = event.clientY; - }; - - const autoScroll = () => { - const list = folderListRef.current; - if (list) { - const rect = list.getBoundingClientRect(); - const edgeSize = Math.min(64, rect.height * 0.2); - const topDistance = pointerYRef.current - rect.top; - const bottomDistance = rect.bottom - pointerYRef.current; - let velocity = 0; - - if (topDistance < edgeSize) { - velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14; - } else if (bottomDistance < edgeSize) { - velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14; - } - - if (velocity !== 0) list.scrollTop += velocity; - } - autoScrollFrameRef.current = requestAnimationFrame(autoScroll); - }; - - window.addEventListener("pointermove", handlePointerMove, { passive: true }); - autoScrollFrameRef.current = requestAnimationFrame(autoScroll); - return () => { - window.removeEventListener("pointermove", handlePointerMove); - if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current); - autoScrollFrameRef.current = null; - }; - }, [draggedFolderId]); - - const displayedFolders = useMemo(() => { - if (librarySort === "custom") return customFolders; - return [...folders].sort((a, b) => { - const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); - return librarySort === "az" ? result : -result; - }); - }, [customFolders, folders, librarySort]); - - const setLibrarySort = (sort: LibrarySort) => { - window.localStorage.setItem(LIBRARY_SORT_KEY, sort); - setLibrarySortState(sort); - }; - - const handleReorder = (orderedIds: number[]) => { - const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder])); - const next = orderedIds - .map((id) => byId.get(id)) - .filter((folder): folder is Folder => folder !== undefined); - customFoldersRef.current = next; - setCustomFolders(next); - }; - - const finishReorder = () => { - const nextIds = customFoldersRef.current.map((folder) => folder.id); - setDraggedFolderId(null); - const currentIds = folders.map((folder) => folder.id); - if (nextIds.some((id, index) => id !== currentIds[index])) { - void reorderFolders(nextIds); - } - }; - - const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => { - const current = customFoldersRef.current; - const currentIndex = current.findIndex((folder) => folder.id === folderId); - const nextIndex = currentIndex + direction; - if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return; - - const next = [...current]; - [next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]]; - customFoldersRef.current = next; - setCustomFolders(next); - // Debounce the DB write so a held arrow key doesn't fire one per keystroke; - // the local order updates immediately, only the persist waits to settle. - if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); - keyboardPersistRef.current = setTimeout(() => { - keyboardPersistRef.current = null; - void reorderFolders(customFoldersRef.current.map((folder) => folder.id)); - }, 400); - }; - - const handleAddFolder = () => { - setFolderPickerOpen(true); - }; - - const startCreatingAlbum = () => { - setCreatingAlbum(true); - setNewAlbumName(""); - setTimeout(() => newAlbumInputRef.current?.focus(), 0); - }; - - const handleCreateAlbum = async () => { - const name = newAlbumName.trim(); - if (!name) { - setCreatingAlbum(false); - return; - } - if (createAlbumPending) return; - setCreateAlbumPending(true); - try { - const album = await createAlbum(name); - setNewAlbumName(""); - setCreatingAlbum(false); - useGalleryStore.getState().viewAlbum(album.id); - } finally { - setCreateAlbumPending(false); - } - }; - - const exitManageAlbums = () => { - setManageAlbums(false); - setManageSelectedIds(new Set()); - setConfirmingAlbumDelete(false); - }; - - const toggleManageSelected = (albumId: number) => { - setManageSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(albumId)) next.delete(albumId); - else next.add(albumId); - return next; - }); - setConfirmingAlbumDelete(false); - }; - - const handleDeleteSelectedAlbums = async () => { - const ids = Array.from(manageSelectedIds); - if (ids.length === 0) return; - await deleteAlbums(ids); - exitManageAlbums(); - }; return ( ); } diff --git a/src/components/sidebar/AlbumItem.tsx b/src/components/sidebar/AlbumItem.tsx new file mode 100644 index 0000000..231e1a8 --- /dev/null +++ b/src/components/sidebar/AlbumItem.tsx @@ -0,0 +1,176 @@ +import { useState } from "react"; +import { Reorder, useDragControls } from "framer-motion"; +import { useGalleryStore, type Album } from "../../store"; +import { mediaSrc } from "../../lib/mediaSrc"; +import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; +import { InlineConfirm } from "../InlineConfirm"; +import { InlineRename } from "../InlineRename"; +import { Tooltip } from "../Tooltip"; +import { CheckIcon, PhotoIcon } from "../icons"; + +export function AlbumItem({ + album, + manageMode = false, + selectedForManage = false, + onToggleManage, + reorderable = false, + onDragStart, + onDragEnd, +}: { + album: Album; + manageMode?: boolean; + selectedForManage?: boolean; + onToggleManage?: () => void; + reorderable?: boolean; + onDragStart?: () => void; + onDragEnd?: () => void; +}) { + const dragControls = useDragControls(); + const viewAlbum = useGalleryStore((state) => state.viewAlbum); + const renameAlbum = useGalleryStore((state) => state.renameAlbum); + const deleteAlbum = useGalleryStore((state) => state.deleteAlbum); + const activeView = useGalleryStore((state) => state.activeView); + const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); + const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id; + + const [menu, setMenu] = useState<{ x: number; y: number } | null>(null); + const [renaming, setRenaming] = useState(false); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + const cover = mediaSrc(album.cover_thumbnail_path); + + const row = ( +
{ + if (manageMode) { + onToggleManage?.(); + } else if (!renaming) { + viewAlbum(album.id); + } + }} + onKeyDown={(e) => { + if (e.target !== e.currentTarget) return; + if (renaming || (e.key !== "Enter" && e.key !== " ")) return; + e.preventDefault(); + if (manageMode) { + onToggleManage?.(); + } else { + viewAlbum(album.id); + } + }} + onContextMenu={(e) => { + if (manageMode) return; + e.preventDefault(); + e.stopPropagation(); + setMenu({ x: e.clientX, y: e.clientY }); + }} + > + {/* Manage-mode selection checkbox */} + {manageMode ? ( +
+ +
+ ) : null} + + {/* Drag handle — hover-revealed, reorders albums */} + {reorderable ? ( + + + + ) : null} + + {/* Cover thumbnail — distinguishes albums from folder rows */} +
+ {cover ? ( + + ) : ( +
+ +
+ )} +
+ +
+ {renaming ? ( + renameAlbum(album.id, next)} + onClose={() => setRenaming(false)} + /> + ) : ( +
+ {album.name} +
+ )} +
{album.image_count.toLocaleString()}
+
+ + {!renaming && confirmingRemoval ? ( + { void deleteAlbum(album.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> + ) : null} + + {menu ? ( + setMenu(null)}> + setRenaming(true)} /> + + setConfirmingRemoval(true)} /> + + ) : null} +
+ ); + + if (reorderable) { + return ( + + {row} + + ); + } + return row; +} + + diff --git a/src/components/sidebar/AlbumSection.tsx b/src/components/sidebar/AlbumSection.tsx new file mode 100644 index 0000000..677271b --- /dev/null +++ b/src/components/sidebar/AlbumSection.tsx @@ -0,0 +1,221 @@ +import { useRef, useState } from "react"; +import { Reorder } from "framer-motion"; +import { useGalleryStore } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { PlusIcon } from "../icons"; +import { AlbumItem } from "./AlbumItem"; +import { useAlbumOrdering } from "./useAlbumOrdering"; + +export function AlbumSection() { + const albums = useGalleryStore((state) => state.albums); + const createAlbum = useGalleryStore((state) => state.createAlbum); + const deleteAlbums = useGalleryStore((state) => state.deleteAlbums); + const reorderAlbums = useGalleryStore((state) => state.reorderAlbums); + const [creatingAlbum, setCreatingAlbum] = useState(false); + const [createAlbumPending, setCreateAlbumPending] = useState(false); + const [newAlbumName, setNewAlbumName] = useState(""); + const newAlbumInputRef = useRef(null); + const [manageAlbums, setManageAlbums] = useState(false); + const [manageSelectedIds, setManageSelectedIds] = useState>(new Set()); + const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false); + const { finishAlbumReorder, handleAlbumReorder, orderedAlbums, setDraggingAlbum } = useAlbumOrdering(albums, reorderAlbums); + + const startCreatingAlbum = () => { + setCreatingAlbum(true); + setNewAlbumName(""); + setTimeout(() => newAlbumInputRef.current?.focus(), 0); + }; + + const handleCreateAlbum = async () => { + const name = newAlbumName.trim(); + if (!name) { + setCreatingAlbum(false); + return; + } + if (createAlbumPending) return; + setCreateAlbumPending(true); + try { + const album = await createAlbum(name); + setNewAlbumName(""); + setCreatingAlbum(false); + useGalleryStore.getState().viewAlbum(album.id); + } finally { + setCreateAlbumPending(false); + } + }; + + const exitManageAlbums = () => { + setManageAlbums(false); + setManageSelectedIds(new Set()); + setConfirmingAlbumDelete(false); + }; + + const toggleManageSelected = (albumId: number) => { + setManageSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(albumId)) next.delete(albumId); + else next.add(albumId); + return next; + }); + setConfirmingAlbumDelete(false); + }; + + const handleDeleteSelectedAlbums = async () => { + const ids = Array.from(manageSelectedIds); + if (ids.length === 0) return; + await deleteAlbums(ids); + exitManageAlbums(); + }; + + return ( +
+
+ + {manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"} + + {manageAlbums ? ( + + ) : ( +
+ {albums.length > 0 ? ( + + + + ) : null} + + + +
+ )} +
+ + {manageAlbums ? ( +
+ {confirmingAlbumDelete ? ( +
+

+ Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in + the library — only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed. +

+
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ ) : null} + +
+ {creatingAlbum ? ( +
{ + e.preventDefault(); + void handleCreateAlbum(); + }} + > + setNewAlbumName(e.target.value)} + disabled={createAlbumPending} + onKeyDown={(e) => { + if (createAlbumPending) return; + if (e.key === "Escape") { + setCreatingAlbum(false); + setNewAlbumName(""); + } + }} + onBlur={() => void handleCreateAlbum()} + /> +
+ ) : null} + + {albums.length === 0 && !creatingAlbum ? ( +

+ Select images and “Add to album” to start curating +

+ ) : manageAlbums ? ( + albums.map((album) => ( + toggleManageSelected(album.id)} + /> + )) + ) : ( + album.id)} + onReorder={handleAlbumReorder} + className="space-y-px" + > + {orderedAlbums.map((album) => ( + setDraggingAlbum(true)} + onDragEnd={finishAlbumReorder} + /> + ))} + + )} +
+
+ ); +} diff --git a/src/components/sidebar/FolderItem.tsx b/src/components/sidebar/FolderItem.tsx new file mode 100644 index 0000000..7b30207 --- /dev/null +++ b/src/components/sidebar/FolderItem.tsx @@ -0,0 +1,227 @@ +import { useState, type MouseEvent } from "react"; +import { Reorder, useDragControls } from "framer-motion"; +import { open } from "@tauri-apps/plugin-dialog"; +import { useGalleryStore, type Folder, type IndexProgress } from "../../store"; +import { ContextMenu, MenuItem, MenuSeparator } from "../menu"; +import { InlineConfirm } from "../InlineConfirm"; +import { InlineRename } from "../InlineRename"; +import { Tooltip } from "../Tooltip"; +import { CloseIcon, FolderIcon } from "../icons"; + +export function FolderItem({ + folder, + selected, + progress, + customOrdering, + dragging, + onDragStart, + onDragEnd, + onKeyboardMove, +}: { + folder: Folder; + selected: boolean; + progress: IndexProgress | undefined; + customOrdering: boolean; + dragging: boolean; + onDragStart: (pointerY: number) => void; + onDragEnd: () => void; + onKeyboardMove: (direction: -1 | 1) => void; +}) { + const dragControls = useDragControls(); + const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); + const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); + const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused); + const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]); + const isMuted = mutedFolderIds.includes(folder.id); + // "Fully paused" only when every worker for this folder is paused. + const isPausedAll = !!folderWorkers && + folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging; + const isIndexing = progress && !progress.done; + const isMissing = !!folder.scan_error && !isIndexing; + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [renaming, setRenaming] = useState(false); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }; + + const handleLocateFolder = async () => { + const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` }); + if (picked && typeof picked === "string") { + await updateFolderPath(folder.id, picked); + } + }; + + return ( + +
!renaming && selectFolder(folder.id)} + onContextMenu={handleContextMenu} + > + {customOrdering ? ( + + + + ) : null} + {isMissing ? ( + + + + + + ) : ( + + )} + +
+ {renaming ? ( + renameFolder(folder.id, next)} + onClose={() => setRenaming(false)} + /> + ) : ( +
+ {folder.name} +
+ )} + {isIndexing ? ( + <> +
{progress.indexed}/{progress.total}
+
+
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} + /> +
+ + ) : ( +
{folder.image_count.toLocaleString()}
+ )} +
+ + {/* Hover action buttons */} + {!renaming && ( + confirmingRemoval ? ( + { void removeFolder(folder.id); setConfirmingRemoval(false); }} + onCancel={() => setConfirmingRemoval(false)} + /> + ) : ( +
+ + + + + + +
+ ) + )} +
+ + {isMissing && ( +
+

Folder not found

+

+ This folder may have been moved or renamed. Locate it to resume, or remove it from the app. +

+
+ + +
+
+ )} + + {contextMenu && ( + setContextMenu(null)}> + void reindexFolder(folder.id)} /> + setRenaming(true)} /> + setAllWorkersPaused(folder.id, !isPausedAll)} + /> + toggleMutedFolder(folder.id)} + /> + {folder.scan_error ? void handleLocateFolder()} /> : null} + + setConfirmingRemoval(true)} /> + + )} + + ); +} + + diff --git a/src/components/sidebar/LibrarySection.tsx b/src/components/sidebar/LibrarySection.tsx new file mode 100644 index 0000000..fb3dc91 --- /dev/null +++ b/src/components/sidebar/LibrarySection.tsx @@ -0,0 +1,81 @@ +import { Reorder } from "framer-motion"; +import { useGalleryStore } from "../../store"; +import { Dropdown } from "../menu"; +import { FolderItem } from "./FolderItem"; +import { useFolderOrdering } from "./useFolderOrdering"; + +export function LibrarySection() { + const folders = useGalleryStore((state) => state.folders); + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); + const indexingProgress = useGalleryStore((state) => state.indexingProgress); + const reorderFolders = useGalleryStore((state) => state.reorderFolders); + + const { + customOrdering, + displayedFolders, + draggedFolderId, + finishReorder, + folderListRef, + handleReorder, + librarySort, + moveFolderByKeyboard, + pointerYRef, + setDraggedFolderId, + setLibrarySort, + } = useFolderOrdering(folders, reorderFolders); + + return ( + <> + {folders.length > 0 && ( +
+ Libraries + +
+ )} + + folder.id)} + onReorder={customOrdering ? handleReorder : () => {}} + layoutScroll + className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0" + > + {folders.length === 0 ? ( +

+ Add a folder to get started +

+ ) : ( + displayedFolders.map((folder) => ( + { + pointerYRef.current = pointerY; + setDraggedFolderId(folder.id); + }} + onDragEnd={finishReorder} + onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)} + /> + )) + )} +
+ + ); +} diff --git a/src/components/sidebar/NavItem.tsx b/src/components/sidebar/NavItem.tsx new file mode 100644 index 0000000..179e216 --- /dev/null +++ b/src/components/sidebar/NavItem.tsx @@ -0,0 +1,27 @@ +export function NavItem({ + label, + iconPath, + active, + onClick, +}: { + label: string; + iconPath: string; + active: boolean; + onClick: () => void; +}) { + return ( +
+ + + + {label} +
+ ); +} + + diff --git a/src/components/sidebar/types.ts b/src/components/sidebar/types.ts new file mode 100644 index 0000000..81242f3 --- /dev/null +++ b/src/components/sidebar/types.ts @@ -0,0 +1,3 @@ +export type LibrarySort = "az" | "za" | "custom"; + +export const LIBRARY_SORT_KEY = "phokus-library-sort"; diff --git a/src/components/sidebar/useAlbumOrdering.ts b/src/components/sidebar/useAlbumOrdering.ts new file mode 100644 index 0000000..535086d --- /dev/null +++ b/src/components/sidebar/useAlbumOrdering.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; +import { useGalleryStore, type Album } from "../../store"; + +export function useAlbumOrdering(albums: Album[], reorderAlbums: (ids: number[]) => Promise) { + const [orderedAlbums, setOrderedAlbums] = useState(albums); + const orderedAlbumsRef = useRef(albums); + const [draggingAlbum, setDraggingAlbum] = useState(false); + + // Keep the local drag order in sync with the store except mid-drag, so a + // background album refresh doesn't yank the row out from under the pointer. + useEffect(() => { + if (draggingAlbum) return; + setOrderedAlbums(albums); + orderedAlbumsRef.current = albums; + }, [albums, draggingAlbum]); + + const handleAlbumReorder = (ids: number[]) => { + const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album])); + const next = ids + .map((id) => byId.get(id)) + .filter((album): album is Album => album !== undefined); + orderedAlbumsRef.current = next; + setOrderedAlbums(next); + }; + + const finishAlbumReorder = () => { + setDraggingAlbum(false); + const nextIds = orderedAlbumsRef.current.map((album) => album.id); + // Read live store order (not the render-time closure) in case albums changed. + const currentIds = useGalleryStore.getState().albums.map((album) => album.id); + const snapshotIds = albums.map((album) => album.id); + if ( + snapshotIds.length !== currentIds.length || + snapshotIds.some((id, index) => id !== currentIds[index]) + ) { + orderedAlbumsRef.current = useGalleryStore.getState().albums; + setOrderedAlbums(orderedAlbumsRef.current); + return; + } + if ( + nextIds.length !== currentIds.length || + nextIds.some((id, index) => id !== currentIds[index]) + ) { + void reorderAlbums(nextIds); + } + }; + + return { + finishAlbumReorder, + handleAlbumReorder, + orderedAlbums, + setDraggingAlbum, + }; +} diff --git a/src/components/sidebar/useFolderOrdering.ts b/src/components/sidebar/useFolderOrdering.ts new file mode 100644 index 0000000..c08a2e7 --- /dev/null +++ b/src/components/sidebar/useFolderOrdering.ts @@ -0,0 +1,130 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { type Folder } from "../../store"; +import { LIBRARY_SORT_KEY, type LibrarySort } from "./types"; + +export function useFolderOrdering(folders: Folder[], reorderFolders: (ids: number[]) => Promise) { + const [librarySort, setLibrarySortState] = useState(() => { + const saved = window.localStorage.getItem(LIBRARY_SORT_KEY); + return saved === "za" || saved === "custom" ? saved : "az"; + }); + const [customFolders, setCustomFolders] = useState(folders); + const [draggedFolderId, setDraggedFolderId] = useState(null); + const folderListRef = useRef(null); + const customFoldersRef = useRef(folders); + const pointerYRef = useRef(0); + const autoScrollFrameRef = useRef(null); + const keyboardPersistRef = useRef | null>(null); + + useEffect( + () => () => { + if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); + }, + [], + ); + + useEffect(() => { + if (draggedFolderId !== null) return; + setCustomFolders(folders); + customFoldersRef.current = folders; + }, [folders, draggedFolderId]); + + useEffect(() => { + if (draggedFolderId === null) return; + + const handlePointerMove = (event: PointerEvent) => { + pointerYRef.current = event.clientY; + }; + + const autoScroll = () => { + const list = folderListRef.current; + if (list) { + const rect = list.getBoundingClientRect(); + const edgeSize = Math.min(64, rect.height * 0.2); + const topDistance = pointerYRef.current - rect.top; + const bottomDistance = rect.bottom - pointerYRef.current; + let velocity = 0; + + if (topDistance < edgeSize) { + velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14; + } else if (bottomDistance < edgeSize) { + velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14; + } + + if (velocity !== 0) list.scrollTop += velocity; + } + autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + }; + + window.addEventListener("pointermove", handlePointerMove, { passive: true }); + autoScrollFrameRef.current = requestAnimationFrame(autoScroll); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + }; + }, [draggedFolderId]); + + const displayedFolders = useMemo(() => { + if (librarySort === "custom") return customFolders; + return [...folders].sort((a, b) => { + const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }); + return librarySort === "az" ? result : -result; + }); + }, [customFolders, folders, librarySort]); + + const setLibrarySort = (sort: LibrarySort) => { + window.localStorage.setItem(LIBRARY_SORT_KEY, sort); + setLibrarySortState(sort); + }; + + const handleReorder = (orderedIds: number[]) => { + const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder])); + const next = orderedIds + .map((id) => byId.get(id)) + .filter((folder): folder is Folder => folder !== undefined); + customFoldersRef.current = next; + setCustomFolders(next); + }; + + const finishReorder = () => { + const nextIds = customFoldersRef.current.map((folder) => folder.id); + setDraggedFolderId(null); + const currentIds = folders.map((folder) => folder.id); + if (nextIds.some((id, index) => id !== currentIds[index])) { + void reorderFolders(nextIds); + } + }; + + const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => { + const current = customFoldersRef.current; + const currentIndex = current.findIndex((folder) => folder.id === folderId); + const nextIndex = currentIndex + direction; + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return; + + const next = [...current]; + [next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]]; + customFoldersRef.current = next; + setCustomFolders(next); + // Debounce the DB write so a held arrow key doesn't fire one per keystroke; + // the local order updates immediately, only the persist waits to settle. + if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current); + keyboardPersistRef.current = setTimeout(() => { + keyboardPersistRef.current = null; + void reorderFolders(customFoldersRef.current.map((folder) => folder.id)); + }, 400); + }; + + return { + customOrdering: librarySort === "custom", + displayedFolders, + draggedFolderId, + finishReorder, + folderListRef, + handleReorder, + librarySort, + moveFolderByKeyboard, + pointerYRef, + setDraggedFolderId, + setLibrarySort, + }; +} From 2149b4cad5f84190deae9c9a9b7286bb1d2a5f77 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:04:06 +0100 Subject: [PATCH 18/27] refactor(bg tasks): modularize component Break down the monolithic BackgroundTasks component into smaller, highly cohesive modules to improve maintainability and readability. - Extract complex UI components into dedicated files (`BackgroundTaskSummary`, `ExpandedTaskPanel`, `TaskStagePill`, `TaskProgressBar`, `BackgroundTaskActions`, and `FailedWorkerItemRow`). - Move task construction, duplicate scan formatting, and progress calculation logic into a dedicated `taskModel.ts` utility file. - Relocate shared interfaces and constants to `types.ts` to cleanly decouple data structures from the presentation layer. --- src/components/BackgroundTasks.tsx | 592 ++---------------- .../backgroundTasks/BackgroundTaskActions.tsx | 66 ++ .../backgroundTasks/BackgroundTaskSummary.tsx | 78 +++ .../backgroundTasks/ExpandedTaskPanel.tsx | 84 +++ .../backgroundTasks/FailedWorkerItemRow.tsx | 28 + .../backgroundTasks/TaskProgressBar.tsx | 24 + .../backgroundTasks/TaskStagePill.tsx | 65 ++ src/components/backgroundTasks/taskModel.ts | 178 ++++++ src/components/backgroundTasks/types.ts | 37 ++ 9 files changed, 626 insertions(+), 526 deletions(-) create mode 100644 src/components/backgroundTasks/BackgroundTaskActions.tsx create mode 100644 src/components/backgroundTasks/BackgroundTaskSummary.tsx create mode 100644 src/components/backgroundTasks/ExpandedTaskPanel.tsx create mode 100644 src/components/backgroundTasks/FailedWorkerItemRow.tsx create mode 100644 src/components/backgroundTasks/TaskProgressBar.tsx create mode 100644 src/components/backgroundTasks/TaskStagePill.tsx create mode 100644 src/components/backgroundTasks/taskModel.ts create mode 100644 src/components/backgroundTasks/types.ts diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index 84d78cb..555168e 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,69 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { invoke } from "@tauri-apps/api/core"; -import { revealItemInDir } from "@tauri-apps/plugin-opener"; -import { useGalleryStore, WorkerKey } from "../store"; -import { Tooltip } from "./Tooltip"; -import { ChevronDownIcon, CloseIcon, PlayIcon, WarningIcon } from "./icons"; - -const WORKER_FOR_STAGE: Record = { - Thumbnails: "thumbnail", - Metadata: "metadata", - Embeddings: "embedding", - Tags: "tagging", -}; - -interface TaskStage { - label: string; - detail: string; - progress: number | null; // 0–100, or null for indeterminate - failed: boolean; -} - -interface Task { - id: number; - name: string; - stages: TaskStage[]; - isActive: boolean; - hasFailedEmbeddings: boolean; - hasFailedTagging: boolean; - hasFailedCaptions: boolean; - pendingMediaWork: number; - embeddingProcessed: number; - embeddingTotal: number; - currentFile: string | null; - snapshot: string; -} - -interface FailedWorkerItem { - image_id: number; - filename: string; - path: string; - error: string | null; -} - -function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { - return ( -
- -
-

{item.filename}

- {item.error && ( -

{item.error}

- )} -
- - - -
- ); -} +import { useGalleryStore, type WorkerKey } from "../store"; +import { BackgroundTaskSummary } from "./backgroundTasks/BackgroundTaskSummary"; +import { ExpandedTaskPanel } from "./backgroundTasks/ExpandedTaskPanel"; +import { buildDuplicateScanTask, buildFolderTasks, taskHasTerminalFailure, taskProgress } from "./backgroundTasks/taskModel"; +import type { BackgroundTask, FailedWorkerItem } from "./backgroundTasks/types"; export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); @@ -74,31 +15,29 @@ export function BackgroundTasks() { const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); + const workerPaused = useGalleryStore((state) => state.workerPaused); + const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); + const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); const [failedTaggingItems, setFailedTaggingItems] = useState>({}); - const workerPaused = useGalleryStore((state) => state.workerPaused); - const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); - const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); - useEffect(() => { void loadWorkerStates(); }, [folders, loadWorkerStates]); - // Fetch failed filenames whenever the expanded panel opens or failure counts change. const failedEmbeddingCounts = useMemo( () => Object.fromEntries( - Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), + Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.embedding_failed ?? 0]), ), [mediaJobProgress], ); const failedTaggingCounts = useMemo( () => Object.fromEntries( - Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]), + Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.tagging_failed ?? 0]), ), [mediaJobProgress], ); @@ -125,475 +64,76 @@ export function BackgroundTasks() { } }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); - const isWorkerPaused = (folderId: number, worker: WorkerKey) => { - return workerPaused[folderId]?.[worker] ?? false; - }; + const folderTasks = useMemo( + () => + buildFolderTasks({ + dismissed, + folders, + indexingProgress, + mediaJobProgress, + workerPaused, + }), + [dismissed, folders, indexingProgress, mediaJobProgress, workerPaused], + ); + + const duplicateScanTask = useMemo( + () => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress), + [duplicateScanning, duplicateScanProgress], + ); + const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks; + + if (allTasks.length === 0) return null; + + const isWorkerPaused = (folderId: number, worker: WorkerKey) => workerPaused[folderId]?.[worker] ?? false; const toggleWorker = (folderId: number, worker: WorkerKey) => { setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker)); }; - const dismissTask = (id: number, snapshot: string) => { - if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed - setDismissed((prev) => ({ ...prev, [id]: snapshot })); + const dismissTask = (task: BackgroundTask) => { + if (task.id < 0) return; + setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot })); setExpanded(false); }; - const tasks = useMemo(() => { - return folders - .map((folder): Task | null => { - const index = indexingProgress[folder.id]; - const jobs = mediaJobProgress[folder.id]; - - const thumbnailPending = jobs?.thumbnail_pending ?? 0; - const metadataPending = jobs?.metadata_pending ?? 0; - const embeddingPending = jobs?.embedding_pending ?? 0; - const embeddingReady = jobs?.embedding_ready ?? 0; - const embeddingFailed = jobs?.embedding_failed ?? 0; - const taggingPending = jobs?.tagging_pending ?? 0; - const taggingReady = jobs?.tagging_ready ?? 0; - const taggingFailed = jobs?.tagging_failed ?? 0; - const captionPending = jobs?.caption_pending ?? 0; - const captionReady = jobs?.caption_ready ?? 0; - const captionFailed = jobs?.caption_failed ?? 0; - - // A folder is "actively processing" when something is genuinely moving: - // an in-progress scan, or pending work on a worker that isn't paused. - // Captions have no per-folder pause toggle, so any caption work counts. - const paused = workerPaused[folder.id]; - const isActive = - (!!index && !index.done) || - (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || - (metadataPending > 0 && !(paused?.metadata ?? false)) || - (embeddingPending > 0 && !(paused?.embedding ?? false)) || - (taggingPending > 0 && !(paused?.tagging ?? false)) || - captionPending > 0; - - const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; - const embeddingProcessed = embeddingReady + embeddingFailed; - const embeddingTotal = embeddingProcessed + embeddingPending; - const taggingProcessed = taggingReady + taggingFailed; - const taggingTotal = taggingProcessed + taggingPending; - const captionProcessed = captionReady + captionFailed; - const captionTotal = captionProcessed + captionPending; - const hasFailedEmbeddings = embeddingFailed > 0; - const hasFailedTagging = taggingFailed > 0; - const hasFailedCaptions = captionFailed > 0; - - if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; - - const stages: TaskStage[] = []; - - if (index && !index.done) { - const pct = index.total > 0 ? (index.indexed / index.total) * 100 : 0; - stages.push({ - label: "Scanning", - detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (thumbnailPending > 0) { - stages.push({ - label: "Thumbnails", - detail: thumbnailPending.toLocaleString(), - progress: null, - failed: false, - }); - } - - if (metadataPending > 0) { - stages.push({ - label: "Metadata", - detail: metadataPending.toLocaleString(), - progress: null, - failed: false, - }); - } - - if (embeddingPending > 0) { - const pct = embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0; - stages.push({ - label: "Embeddings", - detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (taggingPending > 0) { - const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0; - stages.push({ - label: "Tags", - detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (captionPending > 0) { - const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0; - stages.push({ - label: "Captions", - detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, - progress: pct, - failed: false, - }); - } - - if (hasFailedEmbeddings && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${embeddingFailed.toLocaleString()} embeddings`, - progress: null, - failed: true, - }); - } - - if (hasFailedTagging && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${taggingFailed.toLocaleString()} tags`, - progress: null, - failed: true, - }); - } - - if (hasFailedCaptions && pendingMediaWork === 0) { - stages.push({ - label: "Failed", - detail: `${captionFailed.toLocaleString()} captions`, - progress: null, - failed: true, - }); - } - - const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; - - return { - id: folder.id, - name: folder.name, - stages, - isActive, - hasFailedEmbeddings, - hasFailedTagging, - hasFailedCaptions, - pendingMediaWork, - embeddingProcessed, - embeddingTotal, - currentFile: index && !index.done ? (index.current_file || null) : null, - snapshot, - }; - }) - .filter((t): t is Task => t !== null) - .filter((t) => dismissed[t.id] !== t.snapshot) - // Surface actively-processing folders first so a paused folder never holds - // the primary (collapsed) slot while another is genuinely working. Array - // sort is stable, so folders within each group keep their existing order. - .sort((a, b) => Number(b.isActive) - Number(a.isActive)); - }, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]); - - // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed - const duplicateScanTask: Task | null = duplicateScanning ? { - id: -1, - name: "Duplicate Scan", - stages: [{ - label: duplicateScanProgress?.phase === "checking" - ? "Checking" - : duplicateScanProgress?.phase === "confirming" - ? "Confirming" - : "Hashing", - detail: duplicateScanProgress - ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` - : "Starting…", - progress: duplicateScanProgress && duplicateScanProgress.total > 0 - ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 - : null, - failed: false, - }], - isActive: true, - hasFailedEmbeddings: false, - hasFailedTagging: false, - hasFailedCaptions: false, - pendingMediaWork: 1, - embeddingProcessed: 0, - embeddingTotal: 0, - currentFile: null, - snapshot: "", - } : null; - - const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks; - - if (allTasks.length === 0) return null; + const retryTask = (task: BackgroundTask) => { + if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); + if (task.hasFailedTagging) void queueTaggingJobs(task.id); + }; const primary = allTasks[0]; - const extraCount = allTasks.length - 1; - const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0); - - // Best progress bar value: use embedding progress if available (most informative), - // otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate. - const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); - const taggingStage = primary.stages.find((s) => s.label === "Tags"); - const scanningStage = primary.stages.find((s) => s.label === "Scanning"); - const duplicateStage = primary.id === -1 ? primary.stages[0] : null; - const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; + const hasFailed = folderTasks.some(taskHasTerminalFailure); + const barProgress = taskProgress(primary); return (
- {/* Slim bar */} -
setExpanded((v) => !v)} - > - {/* Pulse dot */} -
-
-
-
+ setExpanded((value) => !value)} + onToggleWorker={toggleWorker} + primary={primary} + progress={barProgress} + taskCount={allTasks.length} + /> - {/* Folder name */} - {primary.name} - - {/* Stage tags — all active stages visible simultaneously */} -
- {primary.stages.map((stage) => { - const workerKey = WORKER_FOR_STAGE[stage.label]; - const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false; - return ( - - {stage.label} - - {stage.detail} - - {workerKey && ( - - - - )} - - ); - })} -
- - {/* Progress bar — embedding or scanning progress, pulsing if indeterminate */} -
-
-
- - {/* Extra folders badge */} - {extraCount > 0 && ( - - +{extraCount} - - )} - - {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && ( -
- {primary.hasFailedTagging ? ( - - ) : null} - -
- )} - - {/* Expand chevron (only when multiple tasks) */} - {allTasks.length > 1 && ( - - )} - - {/* Dismiss — hidden for system tasks like duplicate scan */} - {primary.id >= 0 && ( - - - - )} -
- - {/* Expanded panel — one row per folder */} - {expanded && ( -
- {allTasks.map((task) => { - const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); - const taskTaggingStage = task.stages.find((s) => s.label === "Tags"); - const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); - const taskDuplicateStage = task.id === -1 ? task.stages[0] : null; - const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null; - const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; - - return ( -
-
- {task.name} - -
- {task.stages.map((stage) => { - const workerKey = WORKER_FOR_STAGE[stage.label]; - const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false; - return ( - - {isPaused && ( - - - - )} - {stage.label} - - {stage.detail} - - {workerKey && ( - - - - )} - - ); - })} -
- -
-
-
- - {taskHasFailed && ( -
- {task.hasFailedTagging ? ( - - ) : null} - -
- )} - - {task.id >= 0 && ( - - - - )} -
- - {task.currentFile && ( -

- {task.currentFile} -

- )} - - {/* Failed worker file lists */} - {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && ( -
- {failedEmbeddingItems[task.id].map((item) => ( - - ))} -
- )} - {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && ( -
- {failedTaggingItems[task.id].map((item) => ( - - ))} -
- )} -
- ); - })} -
- )} + {expanded ? ( + + ) : null}
); } diff --git a/src/components/backgroundTasks/BackgroundTaskActions.tsx b/src/components/backgroundTasks/BackgroundTaskActions.tsx new file mode 100644 index 0000000..df9ea6b --- /dev/null +++ b/src/components/backgroundTasks/BackgroundTaskActions.tsx @@ -0,0 +1,66 @@ +import { Tooltip } from "../Tooltip"; +import { CloseIcon } from "../icons"; +import type { BackgroundTask } from "./types"; + +export function FailureActions({ + onLocate, + onRetry, + task, +}: { + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + task: BackgroundTask; +}) { + if (task.pendingMediaWork !== 0 || (!task.hasFailedEmbeddings && !task.hasFailedTagging)) return null; + + return ( +
+ {task.hasFailedTagging ? ( + + ) : null} + +
+ ); +} + +export function DismissTaskButton({ + onDismiss, + size = "normal", + task, +}: { + onDismiss: (task: BackgroundTask) => void; + size?: "normal" | "small"; + task: BackgroundTask; +}) { + if (task.id < 0) return null; + + return ( + + + + ); +} diff --git a/src/components/backgroundTasks/BackgroundTaskSummary.tsx b/src/components/backgroundTasks/BackgroundTaskSummary.tsx new file mode 100644 index 0000000..d43f6da --- /dev/null +++ b/src/components/backgroundTasks/BackgroundTaskSummary.tsx @@ -0,0 +1,78 @@ +import type { WorkerKey } from "../../store"; +import { ChevronDownIcon } from "../icons"; +import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; +import { TaskProgressBar } from "./TaskProgressBar"; +import { TaskStagePill } from "./TaskStagePill"; +import type { BackgroundTask } from "./types"; + +export function BackgroundTaskSummary({ + expanded, + extraCount, + hasFailed, + isWorkerPaused, + onDismiss, + onLocate, + onRetry, + onToggleExpanded, + onToggleWorker, + primary, + progress, + taskCount, +}: { + expanded: boolean; + extraCount: number; + hasFailed: boolean; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + onDismiss: (task: BackgroundTask) => void; + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + onToggleExpanded: () => void; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + primary: BackgroundTask; + progress: number | null; + taskCount: number; +}) { + return ( +
+
+
+
+
+ + {primary.name} + +
+ {primary.stages.map((stage) => ( + + ))} +
+ + + + {extraCount > 0 ? ( + + +{extraCount} + + ) : null} + + + + {taskCount > 1 ? ( + + ) : null} + + +
+ ); +} diff --git a/src/components/backgroundTasks/ExpandedTaskPanel.tsx b/src/components/backgroundTasks/ExpandedTaskPanel.tsx new file mode 100644 index 0000000..cfd7195 --- /dev/null +++ b/src/components/backgroundTasks/ExpandedTaskPanel.tsx @@ -0,0 +1,84 @@ +import type { WorkerKey } from "../../store"; +import { DismissTaskButton, FailureActions } from "./BackgroundTaskActions"; +import { FailedWorkerItemRow } from "./FailedWorkerItemRow"; +import { taskHasTerminalFailure, taskProgress } from "./taskModel"; +import { TaskProgressBar } from "./TaskProgressBar"; +import { TaskStagePill } from "./TaskStagePill"; +import type { BackgroundTask, FailedWorkerItem } from "./types"; + +export function ExpandedTaskPanel({ + failedEmbeddingItems, + failedTaggingItems, + isWorkerPaused, + onDismiss, + onLocate, + onRetry, + onToggleWorker, + tasks, +}: { + failedEmbeddingItems: Record; + failedTaggingItems: Record; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + onDismiss: (task: BackgroundTask) => void; + onLocate: (folderId: number) => void; + onRetry: (task: BackgroundTask) => void; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + tasks: BackgroundTask[]; +}) { + return ( +
+ {tasks.map((task) => { + const progress = taskProgress(task); + const failed = taskHasTerminalFailure(task); + + return ( +
+
+ {task.name} + +
+ {task.stages.map((stage) => ( + + ))} +
+ + + + {failed ? : null} + + +
+ + {task.currentFile ? ( +

+ {task.currentFile} +

+ ) : null} + + {failed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 ? ( +
+ {failedEmbeddingItems[task.id].map((item) => ( + + ))} +
+ ) : null} + {failed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 ? ( +
+ {failedTaggingItems[task.id].map((item) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/src/components/backgroundTasks/FailedWorkerItemRow.tsx b/src/components/backgroundTasks/FailedWorkerItemRow.tsx new file mode 100644 index 0000000..c97d3c0 --- /dev/null +++ b/src/components/backgroundTasks/FailedWorkerItemRow.tsx @@ -0,0 +1,28 @@ +import { revealItemInDir } from "@tauri-apps/plugin-opener"; +import { Tooltip } from "../Tooltip"; +import { WarningIcon } from "../icons"; +import type { FailedWorkerItem } from "./types"; + +export function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { + return ( +
+ +
+

{item.filename}

+ {item.error ? ( +

{item.error}

+ ) : null} +
+ + + +
+ ); +} diff --git a/src/components/backgroundTasks/TaskProgressBar.tsx b/src/components/backgroundTasks/TaskProgressBar.tsx new file mode 100644 index 0000000..6c3e559 --- /dev/null +++ b/src/components/backgroundTasks/TaskProgressBar.tsx @@ -0,0 +1,24 @@ +export function TaskProgressBar({ + failed, + progress, + widthClass = "w-20", +}: { + failed: boolean; + progress: number | null; + widthClass?: string; +}) { + return ( +
+
+
+ ); +} diff --git a/src/components/backgroundTasks/TaskStagePill.tsx b/src/components/backgroundTasks/TaskStagePill.tsx new file mode 100644 index 0000000..0d2d3ab --- /dev/null +++ b/src/components/backgroundTasks/TaskStagePill.tsx @@ -0,0 +1,65 @@ +import type { WorkerKey } from "../../store"; +import { Tooltip } from "../Tooltip"; +import { PlayIcon } from "../icons"; +import type { TaskStage } from "./types"; +import { WORKER_FOR_STAGE } from "./types"; + +export function TaskStagePill({ + folderId, + isWorkerPaused, + mutedWhenPaused = false, + onToggleWorker, + stage, +}: { + folderId: number; + isWorkerPaused: (folderId: number, worker: WorkerKey) => boolean; + mutedWhenPaused?: boolean; + onToggleWorker: (folderId: number, worker: WorkerKey) => void; + stage: TaskStage; +}) { + const workerKey = WORKER_FOR_STAGE[stage.label]; + const isPaused = workerKey ? isWorkerPaused(folderId, workerKey) : false; + + return ( + + {isPaused && mutedWhenPaused ? ( + + + + ) : null} + {stage.label} + + {stage.detail} + + {workerKey ? ( + + + + ) : null} + + ); +} diff --git a/src/components/backgroundTasks/taskModel.ts b/src/components/backgroundTasks/taskModel.ts new file mode 100644 index 0000000..e21bca6 --- /dev/null +++ b/src/components/backgroundTasks/taskModel.ts @@ -0,0 +1,178 @@ +import type { DuplicateScanProgress, Folder, FolderJobProgress, IndexProgress, WorkerKey } from "../../store"; +import type { BackgroundTask, TaskStage } from "./types"; + +export function buildFolderTasks({ + dismissed, + folders, + indexingProgress, + mediaJobProgress, + workerPaused, +}: { + dismissed: Record; + folders: Folder[]; + indexingProgress: Record; + mediaJobProgress: Record; + workerPaused: Record>; +}): BackgroundTask[] { + return folders + .map((folder): BackgroundTask | null => { + const index = indexingProgress[folder.id]; + const jobs = mediaJobProgress[folder.id]; + + const thumbnailPending = jobs?.thumbnail_pending ?? 0; + const metadataPending = jobs?.metadata_pending ?? 0; + const embeddingPending = jobs?.embedding_pending ?? 0; + const embeddingReady = jobs?.embedding_ready ?? 0; + const embeddingFailed = jobs?.embedding_failed ?? 0; + const taggingPending = jobs?.tagging_pending ?? 0; + const taggingReady = jobs?.tagging_ready ?? 0; + const taggingFailed = jobs?.tagging_failed ?? 0; + const captionPending = jobs?.caption_pending ?? 0; + const captionReady = jobs?.caption_ready ?? 0; + const captionFailed = jobs?.caption_failed ?? 0; + + const paused = workerPaused[folder.id]; + const isActive = + (!!index && !index.done) || + (thumbnailPending > 0 && !(paused?.thumbnail ?? false)) || + (metadataPending > 0 && !(paused?.metadata ?? false)) || + (embeddingPending > 0 && !(paused?.embedding ?? false)) || + (taggingPending > 0 && !(paused?.tagging ?? false)) || + captionPending > 0; + + const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; + const embeddingProcessed = embeddingReady + embeddingFailed; + const embeddingTotal = embeddingProcessed + embeddingPending; + const taggingProcessed = taggingReady + taggingFailed; + const taggingTotal = taggingProcessed + taggingPending; + const captionProcessed = captionReady + captionFailed; + const captionTotal = captionProcessed + captionPending; + const hasFailedEmbeddings = embeddingFailed > 0; + const hasFailedTagging = taggingFailed > 0; + const hasFailedCaptions = captionFailed > 0; + + if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null; + + const stages: TaskStage[] = []; + + if (index && !index.done) { + stages.push({ + label: "Scanning", + detail: `${index.indexed.toLocaleString()} / ${index.total.toLocaleString()}`, + progress: index.total > 0 ? (index.indexed / index.total) * 100 : 0, + failed: false, + }); + } + + if (thumbnailPending > 0) { + stages.push({ label: "Thumbnails", detail: thumbnailPending.toLocaleString(), progress: null, failed: false }); + } + + if (metadataPending > 0) { + stages.push({ label: "Metadata", detail: metadataPending.toLocaleString(), progress: null, failed: false }); + } + + if (embeddingPending > 0) { + stages.push({ + label: "Embeddings", + detail: `${embeddingProcessed.toLocaleString()} / ${embeddingTotal.toLocaleString()}`, + progress: embeddingTotal > 0 ? (embeddingProcessed / embeddingTotal) * 100 : 0, + failed: false, + }); + } + + if (taggingPending > 0) { + stages.push({ + label: "Tags", + detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`, + progress: taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0, + failed: false, + }); + } + + if (captionPending > 0) { + stages.push({ + label: "Captions", + detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`, + progress: captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0, + failed: false, + }); + } + + if (hasFailedEmbeddings && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${embeddingFailed.toLocaleString()} embeddings`, progress: null, failed: true }); + } + + if (hasFailedTagging && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${taggingFailed.toLocaleString()} tags`, progress: null, failed: true }); + } + + if (hasFailedCaptions && pendingMediaWork === 0) { + stages.push({ label: "Failed", detail: `${captionFailed.toLocaleString()} captions`, progress: null, failed: true }); + } + + const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`; + + return { + id: folder.id, + name: folder.name, + stages, + isActive, + hasFailedEmbeddings, + hasFailedTagging, + hasFailedCaptions, + pendingMediaWork, + embeddingProcessed, + embeddingTotal, + currentFile: index && !index.done ? (index.current_file || null) : null, + snapshot, + }; + }) + .filter((task): task is BackgroundTask => task !== null) + .filter((task) => dismissed[task.id] !== task.snapshot) + .sort((a, b) => Number(b.isActive) - Number(a.isActive)); +} + +export function buildDuplicateScanTask(duplicateScanning: boolean, duplicateScanProgress: DuplicateScanProgress | null): BackgroundTask | null { + if (!duplicateScanning) return null; + + return { + id: -1, + name: "Duplicate Scan", + stages: [{ + label: duplicateScanProgress?.phase === "checking" + ? "Checking" + : duplicateScanProgress?.phase === "confirming" + ? "Confirming" + : "Hashing", + detail: duplicateScanProgress + ? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` + : "Starting…", + progress: duplicateScanProgress && duplicateScanProgress.total > 0 + ? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100 + : null, + failed: false, + }], + isActive: true, + hasFailedEmbeddings: false, + hasFailedTagging: false, + hasFailedCaptions: false, + pendingMediaWork: 1, + embeddingProcessed: 0, + embeddingTotal: 0, + currentFile: null, + snapshot: "", + }; +} + +export function taskProgress(task: BackgroundTask): number | null { + const embeddingStage = task.stages.find((stage) => stage.label === "Embeddings"); + const taggingStage = task.stages.find((stage) => stage.label === "Tags"); + const scanningStage = task.stages.find((stage) => stage.label === "Scanning"); + const duplicateStage = task.id === -1 ? task.stages[0] : null; + return embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null; +} + +export function taskHasTerminalFailure(task: BackgroundTask): boolean { + return (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0; +} diff --git a/src/components/backgroundTasks/types.ts b/src/components/backgroundTasks/types.ts new file mode 100644 index 0000000..f956399 --- /dev/null +++ b/src/components/backgroundTasks/types.ts @@ -0,0 +1,37 @@ +import type { WorkerKey } from "../../store"; + +export const WORKER_FOR_STAGE: Record = { + Thumbnails: "thumbnail", + Metadata: "metadata", + Embeddings: "embedding", + Tags: "tagging", +}; + +export interface TaskStage { + label: string; + detail: string; + progress: number | null; + failed: boolean; +} + +export interface BackgroundTask { + id: number; + name: string; + stages: TaskStage[]; + isActive: boolean; + hasFailedEmbeddings: boolean; + hasFailedTagging: boolean; + hasFailedCaptions: boolean; + pendingMediaWork: number; + embeddingProcessed: number; + embeddingTotal: number; + currentFile: string | null; + snapshot: string; +} + +export interface FailedWorkerItem { + image_id: number; + filename: string; + path: string; + error: string | null; +} From 8f424773d28e5112b3e9772bf40491991ebd6bb0 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 19:14:32 +0100 Subject: [PATCH 19/27] refactor(folder-picker): modularize component Break down the monolithic FolderPickerModal component into smaller, highly cohesive modules to improve maintainability and separate concerns. - Extract UI sub-components into dedicated files (`FolderRow`, `StagedFoldersPanel`, and `StatusLine`). - Move path manipulation, string formatting, and breadcrumb utilities into a dedicated `pathUtils.ts` file. - Abstract complex local state, API calls, and event listeners into a custom `useFolderPicker` hook. - Clean up `FolderPickerModal` to act as a lightweight presentation layer. --- src/components/FolderPickerModal.tsx | 463 ++---------------- src/components/folderPicker/FolderRow.tsx | 72 +++ .../folderPicker/StagedFoldersPanel.tsx | 69 +++ src/components/folderPicker/StatusLine.tsx | 13 + src/components/folderPicker/pathUtils.ts | 66 +++ .../folderPicker/useFolderPicker.ts | 212 ++++++++ 6 files changed, 479 insertions(+), 416 deletions(-) create mode 100644 src/components/folderPicker/FolderRow.tsx create mode 100644 src/components/folderPicker/StagedFoldersPanel.tsx create mode 100644 src/components/folderPicker/StatusLine.tsx create mode 100644 src/components/folderPicker/pathUtils.ts create mode 100644 src/components/folderPicker/useFolderPicker.ts diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index 467b9fd..9b1333b 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -1,398 +1,28 @@ -import { useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; import { Tooltip } from "./Tooltip"; -import { CheckIcon, ChevronRightIcon, CloseIcon } from "./icons"; - -function normalizePath(path: string): string { - return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); -} - -function cleanAddressInput(path: string): string { - const trimmed = path.trim(); - if (trimmed.length >= 2) { - const first = trimmed[0]; - const last = trimmed[trimmed.length - 1]; - if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) { - return trimmed.slice(1, -1).trim(); - } - } - return trimmed; -} - -function friendlyDirectoryError(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); - if (/cannot find the path|os error 3|not found|no such file/i.test(message)) { - return "Folder not found. Check the path and try again."; - } - return message; -} - -function folderName(path: string): string { - const trimmed = path.replace(/[\\/]+$/, ""); - const parts = trimmed.split(/[\\/]+/).filter(Boolean); - return parts.length > 0 ? parts[parts.length - 1] : path; -} - -function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] { - if (!path) return [{ label: "This PC / Home", path: null }]; - - const separator = path.includes("\\") ? "\\" : "/"; - const normalized = path.replace(/[\\/]+$/, ""); - const windowsDrive = normalized.match(/^[A-Za-z]:/); - - if (windowsDrive) { - const drive = windowsDrive[0]; - const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean); - let current = drive; - return [ - { label: "This PC", path: null }, - { label: drive, path: drive }, - ...rest.map((part) => { - current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`; - return { label: part, path: current }; - }), - ]; - } - - const parts = normalized.split(/[\\/]+/).filter(Boolean); - let current = separator === "/" ? "" : ""; - return [ - { label: "/", path: null }, - ...parts.map((part) => { - current = `${current}/${part}`; - return { label: part, path: current }; - }), - ]; -} - -function StatusLine({ results }: { results: FolderAddResult[] | null }) { - if (!results) return null; - const added = results.filter((result) => result.status === "added").length; - const skipped = results.filter((result) => result.status === "skipped").length; - const failed = results.filter((result) => result.status === "error").length; - return ( -

- Added {added}, skipped {skipped}, failed {failed}. -

- ); -} - -function FolderRow({ - entry, - selected, - alreadyAdded, - onToggle, - onNavigate, -}: { - entry: DirEntry; - selected: boolean; - alreadyAdded: boolean; - onToggle: () => void; - onNavigate: () => void; -}) { - return ( -
- - - - - - - {alreadyAdded ? ( - - Added - - ) : null} - - - - -
- ); -} - -function StagedFoldersPanel({ - stagedPaths, - onRemove, - onClear, -}: { - stagedPaths: string[]; - onRemove: (path: string) => void; - onClear: () => void; -}) { - return ( - - ); -} +import { CloseIcon } from "./icons"; +import { FolderRow } from "./folderPicker/FolderRow"; +import { StagedFoldersPanel } from "./folderPicker/StagedFoldersPanel"; +import { StatusLine } from "./folderPicker/StatusLine"; +import { normalizePath } from "./folderPicker/pathUtils"; +import { useFolderPicker } from "./folderPicker/useFolderPicker"; export function FolderPickerModal() { - const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen); - const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen); - const folders = useGalleryStore((state) => state.folders); - const listDirectories = useGalleryStore((state) => state.listDirectories); - const addFolders = useGalleryStore((state) => state.addFolders); - - const [listing, setListing] = useState(null); - const [currentPath, setCurrentPath] = useState(null); - const [addressDraft, setAddressDraft] = useState(""); - const [addressEditing, setAddressEditing] = useState(false); - const [stagedPaths, setStagedPaths] = useState([]); - const [loading, setLoading] = useState(false); - const [adding, setAdding] = useState(false); - const [error, setError] = useState(null); - const [results, setResults] = useState(null); - const scrollRef = useRef(null); - const addressInputRef = useRef(null); - - const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]); - const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]); - const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]); + const folderPicker = useFolderPicker(); const virtualizer = useVirtualizer({ - count: listing?.entries.length ?? 0, - getScrollElement: () => scrollRef.current, + count: folderPicker.entries.length, + getScrollElement: () => folderPicker.scrollRef.current, estimateSize: () => 48, overscan: 8, }); - useEffect(() => { - if (!folderPickerOpen) return; - let cancelled = false; - setLoading(true); - setError(null); - void listDirectories(currentPath) - .then((nextListing) => { - if (cancelled) return; - setListing(nextListing); - setAddressDraft(nextListing.current ?? ""); - setAddressEditing(false); - scrollRef.current?.scrollTo({ top: 0, left: 0 }); - }) - .catch((loadError) => { - if (cancelled) return; - setListing({ current: currentPath, parent: null, entries: [] }); - setError(friendlyDirectoryError(loadError)); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [currentPath, folderPickerOpen, listDirectories]); - - useEffect(() => { - if (!folderPickerOpen) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - if (addressEditing) { - setAddressDraft(listing?.current ?? ""); - setAddressEditing(false); - return; - } - setFolderPickerOpen(false); - } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]); - - useEffect(() => { - if (!addressEditing) return; - requestAnimationFrame(() => { - addressInputRef.current?.focus(); - addressInputRef.current?.select(); - }); - }, [addressEditing]); - - useEffect(() => { - if (folderPickerOpen) return; - setCurrentPath(null); - setAddressDraft(""); - setAddressEditing(false); - setListing(null); - setStagedPaths([]); - setError(null); - setResults(null); - setAdding(false); - }, [folderPickerOpen]); - - if (!folderPickerOpen) return null; - - const entries = listing?.entries ?? []; - const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? "")); - const normalizedAddressPath = addressPath ? normalizePath(addressPath) : ""; - const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false; - const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false; - - const togglePath = (path: string) => { - const normalized = normalizePath(path); - if (libraryPaths.has(normalized)) return; - setResults(null); - setStagedPaths((current) => { - const exists = current.some((staged) => normalizePath(staged) === normalized); - return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path]; - }); - }; - - const stagePath = (path: string) => { - const cleaned = cleanAddressInput(path); - if (!cleaned) { - setError("Enter a folder path first."); - return; - } - - const normalized = normalizePath(cleaned); - if (libraryPaths.has(normalized)) { - setError("That folder is already in your library."); - return; - } - if (stagedSet.has(normalized)) { - setError("That folder is already selected."); - return; - } - - setError(null); - setResults(null); - setStagedPaths((current) => [...current, cleaned]); - }; - - const navigateToAddress = () => { - const cleaned = cleanAddressInput(addressDraft); - setResults(null); - setError(null); - setCurrentPath(cleaned || null); - }; - - const beginAddressEdit = () => { - setAddressDraft(listing?.current ?? ""); - setAddressEditing(true); - }; - - const removeStagedPath = (path: string) => { - const normalized = normalizePath(path); - setResults(null); - setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized)); - }; - - const clearStagedPaths = () => { - setResults(null); - setStagedPaths([]); - }; - - const confirmAdd = async () => { - if (stagedPaths.length === 0 || adding) return; - setAdding(true); - setError(null); - try { - const addResults = await addFolders(stagedPaths); - const failed = addResults.filter((result) => result.status === "error"); - setResults(addResults); - if (failed.length > 0) { - setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error")); - setError(failed.map((failure) => failure.data).join("; ")); - return; - } - setFolderPickerOpen(false); - } catch (addError) { - setError(addError instanceof Error ? addError.message : String(addError)); - } finally { - setAdding(false); - } - }; + if (!folderPicker.folderPickerOpen) return null; return (
setFolderPickerOpen(false)} + onClick={() => folderPicker.setFolderPickerOpen(false)} >
setFolderPickerOpen(false)} + onClick={() => folderPicker.setFolderPickerOpen(false)} > @@ -422,36 +52,33 @@ export function FolderPickerModal() { - {addressEditing ? ( + {folderPicker.addressEditing ? (
{ event.preventDefault(); - navigateToAddress(); + folderPicker.navigateToAddress(); }} > { - setAddressDraft(event.target.value); - setResults(null); - }} + value={folderPicker.addressDraft} + onChange={(event) => folderPicker.updateAddressDraft(event.target.value)} placeholder="Paste or type a folder path" spellCheck={false} /> @@ -461,7 +88,7 @@ export function FolderPickerModal() { className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900" >
@@ -490,23 +117,27 @@ export function FolderPickerModal() {
- {error ? ( + {folderPicker.error ? (
- {error} + {folderPicker.error}
) : null} -
- {loading ? ( +
+ {folderPicker.loading ? (
Loading folders...
- ) : entries.length === 0 ? ( + ) : folderPicker.entries.length === 0 ? (
No folders found here.
) : (
{virtualizer.getVirtualItems().map((virtualItem) => { - const entry = entries[virtualItem.index]; + const entry = folderPicker.entries[virtualItem.index]; const normalized = normalizePath(entry.path); return (
togglePath(entry.path)} - onNavigate={() => setCurrentPath(entry.path)} + selected={folderPicker.stagedSet.has(normalized)} + alreadyAdded={folderPicker.libraryPaths.has(normalized)} + onToggle={() => folderPicker.togglePath(entry.path)} + onNavigate={() => folderPicker.setCurrentPath(entry.path)} />
); @@ -541,33 +172,33 @@ export function FolderPickerModal() {