diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..601d738 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Keep LF in the working copy for all text files (prettier enforces LF) +* text=auto eol=lf + +# Windows scripts that genuinely need CRLF +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Binary assets — never touch line endings +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.gif binary +*.ico binary +*.icns binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.mp4 binary +*.onnx binary diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7b2c1a4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,30 @@ +# Build outputs +dist/ +build/ + +# Tauri / Rust (handled by cargo fmt) +src-tauri/ + +# Lock files & generated +pnpm-lock.yaml +*.lock + +# Generated +src/vite-env.d.ts + +# Public assets +public/ + +# Website subpackage (has its own config if needed) +website/ + +# Markdown & docs (hand-written or tool-generated, keep diffs quiet) +*.md +docs/ + +# CI workflows +.github/ +.gitea/ + +# Tool-managed config +.claude/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..01c8d19 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": false, + "singleQuote": true, + "jsxSingleQuote": false, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "always", + "endOfLine": "lf", + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7e5b6..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,12 +50,19 @@ 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. ### Changed +- **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 @@ -122,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 afe615e..dcd4f8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,10 @@ 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`, `TagCloud`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `MenuBar`, `TitleBar`. +- **`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. - State management: Zustand v5, no selectors library — components call `useGalleryStore(s => s.field)` directly. - Styling: Tailwind CSS v4 (Vite plugin, no config file). - Virtualized gallery grid: `@tanstack/react-virtual`. @@ -43,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 @@ -87,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/package.json b/package.json index 3697e06..ce68a51 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,11 @@ "dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open", "dev:vite": "vite", "dev:web": "cd website && pnpm dev", - "format:app": "cd src-tauri && cargo fmt", - "format:check": "cd src-tauri && cargo fmt --check", + "format": "prettier --write .", + "format:all": "pnpm format && pnpm format:rust", + "format:check": "prettier --check .", + "format:rust": "cd src-tauri && cargo fmt", + "format:rust:check": "cd src-tauri && cargo fmt --check", "preview": "vite preview", "tauri": "tauri" }, @@ -45,6 +48,8 @@ "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "prettier": "^3.9.4", + "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4.2.2", "typescript": "~5.8.3", "vite": "^7.0.4" diff --git a/playwright.config.ts b/playwright.config.ts index 6dfc0d9..53cd798 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from '@playwright/test' /** * Read environment variables from file. @@ -76,4 +76,4 @@ export default defineConfig({ // url: 'http://localhost:3000', // reuseExistingServer: !process.env.CI, // }, -}); +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8762e9a..099476c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,12 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) + prettier: + specifier: ^3.9.4 + version: 3.9.4 + prettier-plugin-tailwindcss: + specifier: ^0.8.0 + version: 0.8.0(prettier@3.9.4) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -1180,6 +1186,66 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + prettier-plugin-tailwindcss@0.8.0: + resolution: {integrity: sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.9.4: + resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + engines: {node: '>=14'} + hasBin: true + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -2110,6 +2176,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier-plugin-tailwindcss@0.8.0(prettier@3.9.4): + dependencies: + prettier: 3.9.4 + + prettier@3.9.4: {} + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 diff --git a/src/App.tsx b/src/App.tsx index 1986c0c..2aa17c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,100 +1,100 @@ -import { useEffect } from "react"; -import { useGalleryStore } from "./store"; -import { Sidebar } from "./components/Sidebar"; -import { BackgroundTasks } from "./components/BackgroundTasks"; -import { Toolbar } from "./components/Toolbar"; -import { Gallery } from "./components/Gallery"; -import { Lightbox } from "./components/Lightbox"; -import { ExploreView } from "./components/ExploreView"; -import { DuplicateFinder } from "./components/DuplicateFinder"; -import { Timeline } from "./components/Timeline"; -import { TitleBar } from "./components/TitleBar"; -import { SettingsModal } from "./components/SettingsModal"; -import { FolderPickerModal } from "./components/FolderPickerModal"; -import { UpdateToast } from "./components/UpdateToast"; -import { WhatsNewToast } from "./components/WhatsNewToast"; -import { WhatsNewModal } from "./components/WhatsNewModal"; -import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; -import { DemoPanel } from "./components/DemoPanel"; -import { initializeNotifications } from "./notifications"; +import { useEffect } from 'react' +import { useGalleryStore } from './store' +import { Sidebar } from './components/Sidebar' +import { BackgroundTasks } from './components/BackgroundTasks' +import { Toolbar } from './components/Toolbar' +import { Gallery } from './components/Gallery' +import { Lightbox } from './components/Lightbox' +import { ExploreView } from './components/ExploreView' +import { DuplicateFinder } from './components/DuplicateFinder' +import { Timeline } from './components/Timeline' +import { TitleBar } from './components/TitleBar' +import { SettingsModal } from './components/SettingsModal' +import { FolderPickerModal } from './components/FolderPickerModal' +import { UpdateToast } from './components/UpdateToast' +import { WhatsNewToast } from './components/WhatsNewToast' +import { WhatsNewModal } from './components/WhatsNewModal' +import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay' +import { DemoPanel } from './components/DemoPanel' +import { initializeNotifications } from './notifications' export default function App() { - const loadFolders = useGalleryStore((state) => state.loadFolders); - const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); - const loadImages = useGalleryStore((state) => state.loadImages); - const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); - const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); - const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel); - const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); - const loadAlbums = useGalleryStore((state) => state.loadAlbums); - const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); - const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); - const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist); - const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); - const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); - const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); - const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); - const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); - const initWhatsNew = useGalleryStore((state) => state.initWhatsNew); - const activeView = useGalleryStore((state) => state.activeView); + const loadFolders = useGalleryStore((state) => state.loadFolders) + const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress) + const loadImages = useGalleryStore((state) => state.loadImages) + const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus) + const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus) + const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel) + const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache) + const loadAlbums = useGalleryStore((state) => state.loadAlbums) + const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds) + const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused) + const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist) + const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress) + const loadAppVersion = useGalleryStore((state) => state.loadAppVersion) + const checkForUpdates = useGalleryStore((state) => state.checkForUpdates) + const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus) + const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted) + const initWhatsNew = useGalleryStore((state) => state.initWhatsNew) + const activeView = useGalleryStore((state) => state.activeView) useEffect(() => { - void initializeNotifications(); - void loadMutedFolderIds(); - void loadNotificationsPaused(); - void loadWorkerPausesPersist(); - void loadFfmpegStatus(); - void loadOnboardingCompleted(); + void initializeNotifications() + void loadMutedFolderIds() + void loadNotificationsPaused() + void loadWorkerPausesPersist() + void loadFfmpegStatus() + void loadOnboardingCompleted() // Load the app version first so the What's New toast/modal (which read // appVersion from the store) have it before the greeting can appear. - void loadAppVersion().then(() => initWhatsNew()); + void loadAppVersion().then(() => initWhatsNew()) // Quiet launch check — dev builds have no signed artifacts to update to. if (import.meta.env.PROD) { - void checkForUpdates({ quiet: true }); + void checkForUpdates({ quiet: true }) } loadFolders().then(async () => { - void loadBackgroundJobProgress(); - void loadCaptionModelStatus(); - void loadTaggerModel(); - void loadTaggerModelStatus(); - void loadDuplicateScanCache(); - await loadAlbums(); - await loadImages(true); - if (import.meta.env.MODE === "ui") { - const { applyMockScenario } = await import("./dev/applyMockScenario"); - applyMockScenario(); + void loadBackgroundJobProgress() + void loadCaptionModelStatus() + void loadTaggerModel() + void loadTaggerModelStatus() + void loadDuplicateScanCache() + await loadAlbums() + await loadImages(true) + if (import.meta.env.MODE === 'ui') { + const { applyMockScenario } = await import('./dev/applyMockScenario') + applyMockScenario() } - }); - let unlisten: (() => void) | undefined; + }) + let unlisten: (() => void) | undefined subscribeToProgress().then((fn) => { - unlisten = fn; - }); + unlisten = fn + }) return () => { - unlisten?.(); - }; - }, []); + unlisten?.() + } + }, []) return ( -
+
{/* Custom title bar — sits at the very top */} {/* Main app content below the title bar */} -
+
-
- {activeView === "timeline" ? ( +
+ {activeView === 'timeline' ? ( <> - ) : activeView === "explore" ? ( + ) : activeView === 'explore' ? ( <> - ) : activeView === "duplicates" ? ( + ) : activeView === 'duplicates' ? ( <> @@ -118,5 +118,5 @@ export default function App() { {import.meta.env.DEV && }
- ); + ) } diff --git a/src/changelog.ts b/src/changelog.ts index 35a76d9..bae4a04 100644 --- a/src/changelog.ts +++ b/src/changelog.ts @@ -2,113 +2,117 @@ // data so the "What's New" UI can render it nicely instead of dumping markdown. // Keeping the changelog as the single source of truth means there's no separate // per-release copy to maintain — whatever ships in CHANGELOG.md is what users see. -import changelogRaw from "../CHANGELOG.md?raw"; +import changelogRaw from '../CHANGELOG.md?raw' export interface ChangelogItem { /** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */ - lead: string | null; + lead: string | null /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */ - body: string; + body: string } export interface ChangelogSection { /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */ - title: string; - items: ChangelogItem[]; + title: string + items: ChangelogItem[] } export interface ChangelogEntry { - version: string; - date: string | null; - sections: ChangelogSection[]; + version: string + date: string | null + sections: ChangelogSection[] } // "## [0.1.1] — 2026-06-23" / "## [Unreleased]" -const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/; +const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/ // "### Added" -const SECTION_HEADING = /^###\s+(.+?)\s*$/; +const SECTION_HEADING = /^###\s+(.+?)\s*$/ // "- bullet text" -const BULLET = /^[-*]\s+(.*)$/; +const BULLET = /^[-*]\s+(.*)$/ // Leading "**Title**" optionally followed by an em dash, used as the item's lead-in. // (Item text is whitespace-collapsed before matching, so no dotAll flag needed.) -const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/; +const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/ function toItem(text: string): ChangelogItem { - const collapsed = text.replace(/\s+/g, " ").trim(); - const match = collapsed.match(LEAD); + const collapsed = text.replace(/\s+/g, ' ').trim() + const match = collapsed.match(LEAD) if (match) { - return { lead: match[1].trim(), body: match[2].trim() }; + return { lead: match[1].trim(), body: match[2].trim() } } - return { lead: null, body: collapsed }; + return { lead: null, body: collapsed } } function parseChangelog(raw: string): ChangelogEntry[] { - const lines = raw.split(/\r?\n/); - const entries: ChangelogEntry[] = []; + const lines = raw.split(/\r?\n/) + const entries: ChangelogEntry[] = [] - let entry: ChangelogEntry | null = null; - let section: ChangelogSection | null = null; - let buffer: string[] = []; + let entry: ChangelogEntry | null = null + let section: ChangelogSection | null = null + let buffer: string[] = [] const flushItem = () => { if (section && buffer.length > 0) { - section.items.push(toItem(buffer.join(" "))); + section.items.push(toItem(buffer.join(' '))) } - buffer = []; - }; + buffer = [] + } for (const line of lines) { - const versionMatch = line.match(VERSION_HEADING); + const versionMatch = line.match(VERSION_HEADING) if (versionMatch) { - flushItem(); - section = null; - entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] }; - entries.push(entry); - continue; + flushItem() + section = null + entry = { + version: versionMatch[1].trim(), + date: versionMatch[2]?.trim() ?? null, + sections: [], + } + entries.push(entry) + continue } // Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF). - if (!entry) continue; + if (!entry) continue - const sectionMatch = line.match(SECTION_HEADING); + const sectionMatch = line.match(SECTION_HEADING) if (sectionMatch) { - flushItem(); - section = { title: sectionMatch[1].trim(), items: [] }; - entry.sections.push(section); - continue; + flushItem() + section = { title: sectionMatch[1].trim(), items: [] } + entry.sections.push(section) + continue } - const bulletMatch = line.match(BULLET); + const bulletMatch = line.match(BULLET) if (bulletMatch) { - flushItem(); - buffer.push(bulletMatch[1]); - continue; + flushItem() + buffer.push(bulletMatch[1]) + continue } - if (line.trim() === "") { + if (line.trim() === '') { // A blank line ends a (possibly wrapped) multi-line bullet. - flushItem(); - continue; + flushItem() + continue } // Indented continuation of the current wrapped bullet. if (buffer.length > 0) { - buffer.push(line.trim()); + buffer.push(line.trim()) } } - flushItem(); - return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })); + flushItem() + return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })) } -const ENTRIES = parseChangelog(changelogRaw); +const ENTRIES = parseChangelog(changelogRaw) export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { - if (!version) return null; + if (!version) return null // Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so // dev/UI-lab builds still resolve to the correct changelog entry. - const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, ""); + const normalized = version.replace(/^v/, '').replace(/-[a-z].*/i, '') // Never surface the in-progress [Unreleased] section to users. - if (normalized.toLowerCase() === "unreleased") return null; - return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; + if (normalized.toLowerCase() === 'unreleased') return null + return ENTRIES.find((e) => e.version.replace(/^v/, '') === normalized) ?? null } diff --git a/src/components/AlbumPicker.tsx b/src/components/AlbumPicker.tsx new file mode 100644 index 0000000..4046f68 --- /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/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index aa85224..7e26e44 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,614 +1,155 @@ -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"; - -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 { useEffect, useMemo, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +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); - const indexingProgress = useGalleryStore((state) => state.indexingProgress); - const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); - const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); - const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); - const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); - const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); - const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); - 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); + const folders = useGalleryStore((state) => state.folders) + const indexingProgress = useGalleryStore((state) => state.indexingProgress) + const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress) + const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings) + const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs) + 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< + Record + >({}) + const [failedTaggingItems, setFailedTaggingItems] = useState>( + {} + ) useEffect(() => { - void loadWorkerStates(); - }, [folders, loadWorkerStates]); + 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], - ); + [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], - ); + [mediaJobProgress] + ) useEffect(() => { - if (!expanded) return; + if (!expanded) return for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { if (count > 0) { - invoke("get_failed_embedding_images", { + invoke('get_failed_embedding_images', { folderId: Number(folderId), }) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) - .catch(() => undefined); + .catch(() => undefined) } } for (const [folderId, count] of Object.entries(failedTaggingCounts)) { if (count > 0) { - invoke("get_failed_tagging_images", { + invoke('get_failed_tagging_images', { folderId: Number(folderId), }) .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) - .catch(() => undefined); + .catch(() => undefined) } } - }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); + }, [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)); - }; + 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 })); - setExpanded(false); - }; + 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 retryTask = (task: BackgroundTask) => { + if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id) + if (task.hasFailedTagging) void queueTaggingJobs(task.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 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 primary = allTasks[0] + 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/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 328b175..9f808f3 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -1,89 +1,67 @@ -import { useEffect, useRef, useState } from "react"; -import { useGalleryStore } from "../store"; -import { BulkTagPopover } from "./bulk/BulkTagPopover"; -import { Tooltip } from "./Tooltip" - -type Panel = "tag" | "rating" | "album" | "delete" | null; +import { useEffect, useRef, useState } from 'react' +import { useGalleryStore } from '../store' +import { BulkAlbumPopover } from './bulk/BulkAlbumPopover' +import { BulkDeleteConfirm } from './bulk/BulkDeleteConfirm' +import { BulkRatingPopover } from './bulk/BulkRatingPopover' +import { BulkSelectionSummary } from './bulk/BulkSelectionSummary' +import { BulkTagPopover } from './bulk/BulkTagPopover' +import { type BulkPanel } from './bulk/types' +import { useDismissable } from './menu' +import { Tooltip } from './Tooltip' +import { CloseIcon } from './icons' export function BulkActionBar() { - const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size); - const selectedIds = useGalleryStore((state) => state.gallerySelectedIds); - const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection); - const selectAllGallery = useGalleryStore((state) => state.selectAllGallery); - const loadedCount = useGalleryStore((state) => state.loadedCount); - const totalImages = useGalleryStore((state) => state.totalImages); - const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite); - const bulkSetRating = useGalleryStore((state) => state.bulkSetRating); - 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 selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size) + const selectedIds = useGalleryStore((state) => state.gallerySelectedIds) + const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection) + const selectAllGallery = useGalleryStore((state) => state.selectAllGallery) + const loadedCount = useGalleryStore((state) => state.loadedCount) + const totalImages = useGalleryStore((state) => state.totalImages) + const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite) + const bulkSetRating = useGalleryStore((state) => state.bulkSetRating) + const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected) + const activeView = useGalleryStore((state) => state.activeView) + const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId) + const addToAlbum = useGalleryStore((state) => state.addToAlbum) + const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum) - const [panel, setPanel] = useState(null); - const [deleting, setDeleting] = useState(false); - const [creatingAlbum, setCreatingAlbum] = useState(false); - const [newAlbumName, setNewAlbumName] = useState(""); - const barRef = useRef(null); + const [panel, setPanel] = useState(null) + const [deleting, setDeleting] = useState(false) + 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(() => { - if (selectedCount === 0) { - setPanel(null); - setNewAlbumName(""); - } - }, [selectedCount]); + if (selectedCount === 0) setPanel(null) + }, [selectedCount]) - if (selectedCount === 0) return null; + if (selectedCount === 0) return null - const ids = Array.from(selectedIds); - const inAlbumView = activeView === "album" && selectedAlbumId !== null; - const togglePanel = (next: Exclude) => setPanel((current) => (current === next ? null : next)); + const ids = Array.from(selectedIds) + const inAlbumView = activeView === 'album' && selectedAlbumId !== null + const togglePanel = (next: Exclude) => + setPanel((current) => (current === next ? null : next)) const handleDelete = async () => { - setDeleting(true); + setDeleting(true) try { - await bulkDeleteSelected(); + await bulkDeleteSelected() } finally { - setDeleting(false); - setPanel(null); + setDeleting(false) + setPanel(null) } - }; + } const handlePickAlbum = async (albumId: number) => { - await addToAlbum(albumId, ids); - setPanel(null); - }; + await addToAlbum(albumId, ids) + 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`; + 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` return (
event.stopPropagation()} > -
- {selectedCount} selected - {loadedCount < totalImages || loadedCount > selectedCount ? ( - - - - ) : null} -
+
- - {panel === "tag" ? setPanel(null)} /> : null} + {panel === 'tag' ? setPanel(null)} /> : null}
- - {panel === "rating" ? ( -
- {Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; - return ( - - - - ); - })} - -
+ {panel === 'rating' ? ( + setPanel(null)} /> ) : null}
@@ -160,54 +105,13 @@ export function BulkActionBar() {
- - {panel === "album" ? ( -
-
- {albums.length === 0 ? ( -

No albums yet — create one below.

- ) : ( - albums.map((album) => ( - - )) - )} -
-
{ - event.preventDefault(); - void handleCreateAlbum(); - }} - > - setNewAlbumName(event.target.value)} - disabled={creatingAlbum} - /> - -
-
- ) : null} + {panel === 'album' ? : null}
{inAlbumView ? ( @@ -223,58 +127,35 @@ export function BulkActionBar() {
- - - {panel === "delete" ? ( -
togglePanel('delete')} + disabled={deleting} > -
- - - -

Delete from disk

-
-

- Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. - This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. -

-
- - -
-
+ {deleting ? 'Deleting…' : 'Delete'} + + + {panel === 'delete' ? ( + setPanel(null)} + onDelete={handleDelete} + /> ) : null}
- - + +
- ); + ) } diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx index 200ed7e..509ae35 100644 --- a/src/components/ColorFilter.tsx +++ b/src/components/ColorFilter.tsx @@ -1,75 +1,82 @@ -import { useEffect, useRef, useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useGalleryStore } from "../store"; -import { Tooltip } from "./Tooltip"; +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]; +type Rgb = [number, number, number] // Representative colors for the quick-pick swatches. Each is just an RGB the // distance filter matches against — not a hard bucket. const SWATCHES: { name: string; rgb: Rgb }[] = [ - { name: "Red", rgb: [226, 59, 59] }, - { name: "Orange", rgb: [232, 134, 46] }, - { name: "Yellow", rgb: [242, 207, 46] }, - { name: "Green", rgb: [76, 175, 80] }, - { name: "Teal", rgb: [31, 182, 166] }, - { name: "Blue", rgb: [59, 125, 216] }, - { name: "Purple", rgb: [139, 92, 246] }, - { name: "Pink", rgb: [236, 72, 153] }, - { name: "Brown", rgb: [139, 90, 43] }, - { name: "Black", rgb: [26, 26, 26] }, - { name: "White", rgb: [245, 245, 245] }, - { name: "Gray", rgb: [154, 160, 166] }, -]; + { name: 'Red', rgb: [226, 59, 59] }, + { name: 'Orange', rgb: [232, 134, 46] }, + { name: 'Yellow', rgb: [242, 207, 46] }, + { name: 'Green', rgb: [76, 175, 80] }, + { name: 'Teal', rgb: [31, 182, 166] }, + { name: 'Blue', rgb: [59, 125, 216] }, + { name: 'Purple', rgb: [139, 92, 246] }, + { name: 'Pink', rgb: [236, 72, 153] }, + { name: 'Brown', rgb: [139, 90, 43] }, + { name: 'Black', rgb: [26, 26, 26] }, + { name: 'White', rgb: [245, 245, 245] }, + { name: 'Gray', rgb: [154, 160, 166] }, +] function rgbEquals(a: Rgb | null, b: Rgb): boolean { - return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; + return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2] } function toHex([r, g, b]: Rgb): string { - return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`; + return `#${[r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')}` } function fromHex(hex: string): Rgb { - const n = parseInt(hex.slice(1), 16); - return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + const n = parseInt(hex.slice(1), 16) + return [(n >> 16) & 255, (n >> 8) & 255, n & 255] } export function ColorFilter() { - const colorFilter = useGalleryStore((state) => state.colorFilter); - const setColorFilter = useGalleryStore((state) => state.setColorFilter); - const colorBackfill = useGalleryStore((state) => state.colorBackfill); - const [open, setOpen] = useState(false); - const ref = useRef(null); + const colorFilter = useGalleryStore((state) => state.colorFilter) + const setColorFilter = useGalleryStore((state) => state.setColorFilter) + const colorBackfill = useGalleryStore((state) => state.colorBackfill) + const [open, setOpen] = useState(false) + const ref = useRef(null) - const isActive = colorFilter !== null; - const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb)); + 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 ( -
+
{/* Trigger — a single palette icon; shows the active color as a dot when a filter is applied so the collapsed state still communicates it. */} - +
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( -
+
{colorBackfill && colorBackfill.total > 0 ? ( - + - sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} + sampling {colorBackfill.processed.toLocaleString()}/ + {colorBackfill.total.toLocaleString()} - ) : } + ) : ( + + )} {isActive ? ( - + ) : null}
@@ -159,5 +179,5 @@ export function ColorFilter() { ) : null}
- ); + ) } diff --git a/src/components/DemoPanel.tsx b/src/components/DemoPanel.tsx index e90463d..953b2b5 100644 --- a/src/components/DemoPanel.tsx +++ b/src/components/DemoPanel.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from "react"; -import { FolderJobProgress, useGalleryStore } from "../store"; +import { useEffect, useRef, useState } from 'react' +import { FolderJobProgress, useGalleryStore } from '../store' // Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise // too transient (or unreachable) to capture: the background-worker pipeline, @@ -25,7 +25,7 @@ function emptyProgress(folderId: number): FolderJobProgress { tagging_pending: 0, tagging_ready: 0, tagging_failed: 0, - }; + } } // A believable multi-folder "busy pipeline" — three folders at different stages. @@ -36,56 +36,56 @@ const BUSY_PRESETS: Partial[] = [ { thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 }, // Late stage: embeddings done, tagging running. { embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 }, -]; +] -const DEMO_UPDATE_VERSION = "0.2.0"; +const DEMO_UPDATE_VERSION = '0.2.0' export function DemoPanel() { - const folders = useGalleryStore((state) => state.folders); - const appVersion = useGalleryStore((state) => state.appVersion); - const [open, setOpen] = useState(false); - const downloadTimer = useRef(null); + const folders = useGalleryStore((state) => state.folders) + const appVersion = useGalleryStore((state) => state.appVersion) + const [open, setOpen] = useState(false) + const downloadTimer = useRef(null) useEffect(() => { const onKey = (event: KeyboardEvent) => { - if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") { - event.preventDefault(); - setOpen((value) => !value); + if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'd') { + event.preventDefault() + setOpen((value) => !value) } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) const stopTimer = () => { if (downloadTimer.current !== null) { - window.clearInterval(downloadTimer.current); - downloadTimer.current = null; + window.clearInterval(downloadTimer.current) + downloadTimer.current = null } - }; + } // Stop any running download animation when the panel unmounts. - useEffect(() => stopTimer, []); + useEffect(() => stopTimer, []) const injectBusy = () => { - const progress: Record = {}; + const progress: Record = {} folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => { - progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] }; - }); - useGalleryStore.setState({ mediaJobProgress: progress }); - }; + progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] } + }) + useGalleryStore.setState({ mediaJobProgress: progress }) + } const injectSingleEmbedding = () => { - const folder = folders[0]; - if (!folder) return; + const folder = folders[0] + if (!folder) return useGalleryStore.setState({ mediaJobProgress: { [folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 }, }, - }); - }; + }) + } - const clear = () => useGalleryStore.setState({ mediaJobProgress: {} }); + const clear = () => useGalleryStore.setState({ mediaJobProgress: {} }) // --- Updater flow --------------------------------------------------------- // These drive the real UpdateToast + Settings "About" row. The Install button @@ -93,73 +93,73 @@ export function DemoPanel() { // presentation only — see DemoPanel's header note for the real e2e path. const updateAvailable = () => { - stopTimer(); + stopTimer() useGalleryStore.setState({ - updateStatus: "available", + updateStatus: 'available', updateVersion: DEMO_UPDATE_VERSION, updateProgress: null, updateError: null, updateDismissed: false, - }); - }; + }) + } // Indeterminate pulse, then climb 0 → 100%, then flip to "installing". const simulateDownload = () => { - stopTimer(); + stopTimer() useGalleryStore.setState({ - updateStatus: "downloading", + updateStatus: 'downloading', updateVersion: DEMO_UPDATE_VERSION, updateProgress: null, updateError: null, updateDismissed: false, - }); - let progress = 0; + }) + let progress = 0 window.setTimeout(() => { downloadTimer.current = window.setInterval(() => { - progress = Math.min(progress + 0.07, 1); - useGalleryStore.setState({ updateProgress: progress }); + progress = Math.min(progress + 0.07, 1) + useGalleryStore.setState({ updateProgress: progress }) if (progress >= 1) { - stopTimer(); - useGalleryStore.setState({ updateStatus: "installing" }); + stopTimer() + useGalleryStore.setState({ updateStatus: 'installing' }) } - }, 200); - }, 700); - }; + }, 200) + }, 700) + } const updateInstalling = () => { - stopTimer(); + stopTimer() useGalleryStore.setState({ - updateStatus: "installing", + updateStatus: 'installing', updateVersion: DEMO_UPDATE_VERSION, updateProgress: 1, updateDismissed: false, - }); - }; + }) + } const updateError = () => { - stopTimer(); + stopTimer() useGalleryStore.setState({ - updateStatus: "error", - updateError: "Could not reach the update server (connection timed out).", + updateStatus: 'error', + updateError: 'Could not reach the update server (connection timed out).', updateDismissed: false, - }); - }; + }) + } const updateUpToDate = () => { - stopTimer(); - useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null }); - }; + stopTimer() + useGalleryStore.setState({ updateStatus: 'upToDate', updateVersion: null, updateError: null }) + } const resetUpdate = () => { - stopTimer(); + stopTimer() useGalleryStore.setState({ - updateStatus: "idle", + updateStatus: 'idle', updateVersion: null, updateProgress: null, updateError: null, updateDismissed: false, - }); - }; + }) + } // --- What's New flow ------------------------------------------------------ // Drives the post-update greeting without needing a real version change. The @@ -167,26 +167,32 @@ export function DemoPanel() { // app version, so the current release's notes are what render. const showWhatsNewToast = () => - useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false }); + useGalleryStore.setState({ + whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, + whatsNewOpen: false, + }) - const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null }); + const openWhatsNewModal = () => + useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null }) - const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null }); + const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null }) - if (!open) return null; + if (!open) return null const injectBtn = - "rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25"; + 'rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25' const neutralBtn = - "rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10"; + 'rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10' return (
- Demo · Ctrl+Shift+D + Demo · Ctrl+Shift+D
-

Pipeline

+

+ Pipeline +

Inject a frozen worker-bar state, hide this panel, then screenshot.

@@ -204,7 +210,9 @@ export function DemoPanel() {
-

Update flow

+

+ Update flow +

Drives the real toast (bottom-right) + Settings → About row. Install is inert here.

@@ -231,9 +239,11 @@ export function DemoPanel() {
-

What's new

+

+ What's new +

- Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog. + Post-update greeting for v{appVersion ?? '—'}, sourced from the bundled changelog.

- ); + ) } diff --git a/src/components/DuplicateFinder.tsx b/src/components/DuplicateFinder.tsx index 5d9fc26..4315ee0 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,361 +1,125 @@ -import { useRef, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { DuplicateGroup, useGalleryStore } from "../store"; -import { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { mediaSrc } from "../lib/mediaSrc"; -import { Tooltip } from "./Tooltip"; - -function formatBytes(bytes: number): string { - if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; - if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; - return `${bytes} B`; -} - -function DuplicateGroupCard({ group }: { group: DuplicateGroup }) { - const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds); - const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected); - const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates); - const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length; - const noneSelected = groupSelectedCount === 0; - - // "Keep all but the first" — a common quick action - const handleKeepFirst = () => { - const toDelete = group.images.slice(1).map((img) => img.id); - // Clear any selection for this group first, then add the ones to delete - for (const img of group.images) { - if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id); - } - selectAllDuplicates(toDelete); - }; - - return ( -
- {/* Group header */} -
-
- - {group.images.length} copies - - {formatBytes(group.file_size)} each - - {formatBytes(group.file_size * (group.images.length - 1))} wasted - -
-
- {noneSelected ? ( - - ) : ( - - )} -
-
- - {/* Image grid */} -
- {group.images.map((image) => { - const isSelected = selectedIds.has(image.id); - const src = mediaSrc(image.thumbnail_path); - return ( - - - - ); - })} -
-
- ); -} - -function formatRelativeTime(unixSecs: number): string { - const diff = Math.floor(Date.now() / 1000) - unixSecs; - if (diff < 60) return "just now"; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return `${Math.floor(diff / 86400)}d ago`; -} +import { useRef, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useGalleryStore } from '../store' +import { + DuplicateScanEmptyState, + DuplicateScanIntroState, + DuplicateScanLoadingState, +} from './duplicateFinder/DuplicateFinderEmptyStates' +import { DuplicateFinderHeader } from './duplicateFinder/DuplicateFinderHeader' +import { DuplicateGroupCard } from './duplicateFinder/DuplicateGroupCard' +import { duplicateProgressLabel } from './duplicateFinder/format' export function DuplicateFinder() { - const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); - const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); - const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); - const duplicateScanError = useGalleryStore((state) => state.duplicateScanError); - const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning); - const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds); - const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned); - const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); - const scanDuplicates = useGalleryStore((state) => state.scanDuplicates); - const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection); - const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups); - const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); + const duplicateGroups = useGalleryStore((state) => state.duplicateGroups) + const duplicateScanning = useGalleryStore((state) => state.duplicateScanning) + const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress) + const duplicateScanError = useGalleryStore((state) => state.duplicateScanError) + const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning) + const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds) + const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned) + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId) + const scanDuplicates = useGalleryStore((state) => state.scanDuplicates) + const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection) + const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups) + const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates) - const [deleting, setDeleting] = useState(false); - const [confirmingDelete, setConfirmingDelete] = useState(false); - const [deleteResult, setDeleteResult] = useState(null); + const [deleting, setDeleting] = useState(false) + const [confirmingDelete, setConfirmingDelete] = useState(false) + const [deleteResult, setDeleteResult] = useState(null) // Virtualize the group list so a large result set (e.g. thousands of pairs) // only mounts the on-screen cards. Group cards vary in height (number of // copies wraps across rows), so heights are measured dynamically. - const scrollRef = useRef(null); + const scrollRef = useRef(null) const virtualizer = useVirtualizer({ count: duplicateGroups.length, getScrollElement: () => scrollRef.current, estimateSize: () => 220, overscan: 4, - }); - - const selectedCount = duplicateSelectedIds.size; - const hasResults = duplicateGroups.length > 0; - const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); - const totalWasted = duplicateGroups.reduce( - (sum, g) => sum + g.file_size * (g.images.length - 1), - 0, - ); - const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0); + }) + const selectedCount = duplicateSelectedIds.size + const hasResults = duplicateGroups.length > 0 + const hasScanned = + hasResults || + duplicateLastScanned !== null || + (!duplicateScanning && duplicateScanProgress !== null) const handleDelete = async () => { - setDeleting(true); - setConfirmingDelete(false); - setDeleteResult(null); + setDeleting(true) + setConfirmingDelete(false) + setDeleteResult(null) try { - const deleted = await deleteSelectedDuplicates(); - setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`); + const deleted = await deleteSelectedDuplicates() + setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? '' : 's'}.`) } catch (e) { - setDeleteResult(String(e)); + setDeleteResult(String(e)) } finally { - setDeleting(false); + setDeleting(false) } - }; + } - const progressPercent = - duplicateScanProgress && duplicateScanProgress.total > 0 - ? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100) - : 0; - const progressLabel = duplicateScanProgress - ? duplicateScanProgress.phase === "checking" - ? "Checking file sizes" - : duplicateScanProgress.phase === "hashing" - ? "Hashing duplicate candidates" - : "Confirming exact matches" - : null; + const progressLabel = duplicateProgressLabel(duplicateScanProgress) return (
- {/* Header */} -
-
-
-

Duplicate Finder

-

- {duplicateScanning - ? duplicateScanProgress - ? `${progressLabel}… ${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` - : "Starting scan…" - : hasResults - ? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable` - : duplicateLastScanned !== null - ? "No duplicates found" - : "Scan your library for identical files"} -

- {!duplicateScanning && duplicateLastScanned !== null && ( -

- Last scanned {formatRelativeTime(duplicateLastScanned)} -

- )} -
-
- - {/* Batch select — only shown when there are groups and nothing is selected yet */} - {hasResults && selectedCount === 0 && !deleting && ( - - - - )} - {selectedCount > 0 ? ( - <> - {selectedCount} marked for deletion - -
- - {confirmingDelete && !deleting ? ( - <> - {/* Click-away backdrop */} -
setConfirmingDelete(false)} /> -
-
- - - -

Delete from disk

-
-

- Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. - This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. -

-
- - -
-
- - ) : null} -
- - ) : null} - -
-
+ setConfirmingDelete(false)} + onDelete={handleDelete} + onScan={() => { + setDeleteResult(null) + void scanDuplicates(selectedFolderId) + }} + onSelectKeepFirstAll={selectKeepFirstAllGroups} + selectedCount={selectedCount} + setConfirmingDelete={setConfirmingDelete} + /> - {/* Progress bar */} - {duplicateScanning && duplicateScanProgress ? ( -
-
-
- ) : null} - - {duplicateScanError ? ( -

{duplicateScanError}

- ) : null} - {duplicateScanWarning ? ( -

{duplicateScanWarning}

- ) : null} - {deleteResult ? ( -

{deleteResult}

- ) : null} -
- - {/* Body */} {duplicateScanning && !hasResults ? ( -
-
- {progressLabel ? `${progressLabel}…` : "Preparing scan…"} -
+ ) : !hasScanned ? ( -
-
- - - -

- Finds files with identical content regardless of filename or location. - Click Scan for duplicates to begin. -

-

- Large libraries may take a minute — files are hashed from disk. -

-
-
+ ) : duplicateGroups.length === 0 ? ( -
-

No duplicate files found.

-
+ ) : (
-
+
{virtualizer.getVirtualItems().map((virtualItem) => { - const group = duplicateGroups[virtualItem.index]; - if (!group) return null; + const group = duplicateGroups[virtualItem.index] + if (!group) return null return (
- ); + ) })}
)}
- ); + ) } - diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index 76a522b..31db42d 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -1,1137 +1,55 @@ -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 { FolderScopeDropdown } from "./FolderScopeDropdown"; -import { ThemedDropdown } from "./ThemedDropdown"; -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} -
- setSort(value as TagManageSort)} - options={TAG_MANAGE_SORTS} - ariaLabel="Sort managed tags" - align="right" - /> -
-
-
- -
-
- {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 { useEffect } from 'react' +import { useGalleryStore } from '../store' +import { FolderScopeDropdown } from './FolderScopeDropdown' +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); - const setExploreMode = useGalleryStore((state) => state.setExploreMode); - const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries); - const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading); - const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters); - const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); - const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); - const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); - const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); - const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); - const searchForTag = useGalleryStore((state) => state.searchForTag); - const renameTag = useGalleryStore((state) => state.renameTag); - const deleteTag = useGalleryStore((state) => state.deleteTag); - const resetAiTags = useGalleryStore((state) => state.resetAiTags); - const folders = useGalleryStore((state) => state.folders); - const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); + const exploreMode = useGalleryStore((state) => state.exploreMode) + const setExploreMode = useGalleryStore((state) => state.setExploreMode) + const visualClusterEntries = useGalleryStore((state) => state.visualClusterEntries) + const visualClusterLoading = useGalleryStore((state) => state.visualClusterLoading) + const loadVisualClusters = useGalleryStore((state) => state.loadVisualClusters) + const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries) + const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading) + const loadExploreTags = useGalleryStore((state) => state.loadExploreTags) + const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags) + const showVisualCluster = useGalleryStore((state) => state.showVisualCluster) + const searchForTag = useGalleryStore((state) => state.searchForTag) + const renameTag = useGalleryStore((state) => state.renameTag) + const deleteTag = useGalleryStore((state) => state.deleteTag) + const resetAiTags = useGalleryStore((state) => state.resetAiTags) + const folders = useGalleryStore((state) => state.folders) + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId) // Manage mode lives in the store so it can be opened from elsewhere (Settings). - const manageTags = useGalleryStore((state) => state.tagManagerOpen); - const setManageTags = useGalleryStore((state) => state.setTagManagerOpen); - const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; + const manageTags = useGalleryStore((state) => state.tagManagerOpen) + const setManageTags = useGalleryStore((state) => state.setTagManagerOpen) + const handleDeleteTag = async (tag: string) => { + await deleteTag(tag) + } const tagManagerScopeLabel = selectedFolderId === null - ? "all media" - : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder"; + ? 'all media' + : (folders.find((folder) => folder.id === selectedFolderId)?.name ?? 'the current folder') const handleResetAiTags = async () => { - const count = await resetAiTags(selectedFolderId); - await loadExploreTags({ force: true }); - return count; - }; + const count = await resetAiTags(selectedFolderId) + await loadExploreTags({ force: true }) + return count + } useEffect(() => { - if (exploreMode === "visual") void loadVisualClusters(); - else void loadExploreTags(); - }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]); + if (exploreMode === 'visual') void loadVisualClusters() + else void loadExploreTags() + }, [exploreMode, selectedFolderId, loadVisualClusters, loadExploreTags]) - const loading = exploreMode === "visual" ? visualClusterLoading : exploreTagLoading; - const hasEntries = exploreMode === "visual" ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0; - const entryCount = exploreMode === "visual" ? visualClusterEntries.length : exploreTagEntries.length; - const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); + const loading = exploreMode === 'visual' ? visualClusterLoading : exploreTagLoading + const hasEntries = + exploreMode === 'visual' ? visualClusterEntries.length > 0 : exploreTagEntries.length > 0 + const entryCount = + exploreMode === 'visual' ? visualClusterEntries.length : exploreTagEntries.length + const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE) return (
@@ -1143,48 +61,54 @@ export function ExploreView() {

Explore

{loading - ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" + ? exploreMode === 'visual' + ? 'Computing visual clusters…' + : 'Loading tags…' : hasEntries - ? exploreMode === "visual" - ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` + ? exploreMode === 'visual' + ? `${entryCount} cluster${entryCount !== 1 ? 's' : ''} — click any to open` : manageTags - ? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage` - : visibleTagCount < entryCount - ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` - : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` - : exploreMode === "visual" - ? "No clusters — images need embeddings first" - : "No tags — run the AI tagger or add tags manually"} + ? `${entryCount} tag${entryCount !== 1 ? 's' : ''} available to manage` + : visibleTagCount < entryCount + ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` + : `${entryCount} tag${entryCount !== 1 ? 's' : ''} — click any to search` + : exploreMode === 'visual' + ? 'No clusters — images need embeddings first' + : 'No tags — run the AI tagger or add tags manually'}

- {exploreMode === "tags" && hasEntries ? ( + {exploreMode === 'tags' && hasEntries ? ( ) : null}
@@ -1198,12 +122,12 @@ export function ExploreView() { ) : !hasEntries ? (

- {exploreMode === "visual" - ? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." - : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."} + {exploreMode === 'visual' + ? 'No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar.' + : 'No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview.'}

- ) : exploreMode === "visual" ? ( + ) : exploreMode === 'visual' ? (
@@ -1219,8 +143,12 @@ export function ExploreView() { />
) : ( - + )}
- ); + ) } diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index 4b46aaf..9b0d09a 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -1,423 +1,48 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; -import { Tooltip } from "./Tooltip"; - -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 { useVirtualizer } from '@tanstack/react-virtual' +import { Tooltip } from './Tooltip' +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)} >
event.stopPropagation()} > -
+

Add media folders

-

Choose folders from any location, then add them together.

+

+ Choose folders from any location, then add them together. +

@@ -428,56 +53,58 @@ export function FolderPickerModal() {
- {addressEditing ? ( + {folderPicker.addressEditing ? (
{ - event.preventDefault(); - navigateToAddress(); + event.preventDefault() + folderPicker.navigateToAddress() }} > - + { - setAddressDraft(event.target.value); - setResults(null); - }} + className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:placeholder-gray-500 light-theme:focus:bg-gray-800 min-w-0 flex-1 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 font-mono text-xs text-gray-200 placeholder-gray-600 transition-colors outline-none focus:border-white/25 focus:bg-white/[0.055]" + value={folderPicker.addressDraft} + onChange={(event) => folderPicker.updateAddressDraft(event.target.value)} placeholder="Paste or type a folder path" spellCheck={false} />
) : ( -
+
@@ -497,36 +124,47 @@ export function FolderPickerModal() {
- {error ? ( -
- {error} + {folderPicker.error ? ( +
+ {folderPicker.error}
) : null} -
- {loading ? ( -
Loading folders...
- ) : entries.length === 0 ? ( -
No folders found here.
+
+ {folderPicker.loading ? ( +
+ Loading folders... +
+ ) : folderPicker.entries.length === 0 ? ( +
+ No folders found here. +
) : (
{virtualizer.getVirtualItems().map((virtualItem) => { - const entry = entries[virtualItem.index]; - const normalized = normalizePath(entry.path); + 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)} />
- ); + ) })}
)} @@ -548,38 +186,38 @@ export function FolderPickerModal() {
-
+
- +
- ); + ) } diff --git a/src/components/FolderScopeDropdown.tsx b/src/components/FolderScopeDropdown.tsx index 9bbed40..59da2a1 100644 --- a/src/components/FolderScopeDropdown.tsx +++ b/src/components/FolderScopeDropdown.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef, useState } from "react"; -import { useGalleryStore } from "../store"; -import { Tooltip } from "./Tooltip"; +import { useMemo } from 'react' +import { useGalleryStore } from '../store' +import { Dropdown, DropdownOption } from './menu' /** * In-view folder scope picker for feature views (Timeline / Explore / @@ -8,93 +8,48 @@ 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) - 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/Gallery.tsx b/src/components/Gallery.tsx index 85bc8f3..26252d5 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -1,366 +1,54 @@ -import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; -import { BulkActionBar } from "./BulkActionBar"; -import { Tooltip } from "./Tooltip"; -import { mediaSrc } from "../lib/mediaSrc"; +import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from '../store' +import { BulkActionBar } from './BulkActionBar' +import { ImageContextMenu } from './ImageContextMenu' +import { GalleryEmptyState, GalleryLoadingState } from './gallery/GalleryEmptyState' +import { ImageTile } from './gallery/ImageTile' -const GAP = 6; - -function formatDuration(durationMs: number | null): string | null { - if (!durationMs || durationMs <= 0) return null; - 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 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, - onContextMenu, -}: { - image: ImageRecord; - onClick: () => void; - onContextMenu: (event: React.MouseEvent) => void; -}) { - const [loaded, setLoaded] = useState(false); - const [errored, setErrored] = useState(false); - const findSimilar = useGalleryStore((state) => state.findSimilar); - const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id)); - const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0); - const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected); - const canFindSimilar = image.embedding_status === "ready"; - - const src = mediaSrc(image.thumbnail_path); - - return ( -
- {/* Full-tile click target — opens, or toggles selection while selecting. - A real button (over the non-interactive tile div) keeps it keyboard- - accessible without nesting buttons. */} - - {/* Image / placeholder */} - {src && !errored ? ( - <> - {!loaded &&
} - {image.filename} setLoaded(true)} - onError={() => setErrored(true)} - /> - - ) : ( -
- {image.media_kind === "video" ? ( - - - - ) : ( - - - - )} -
- )} - - {/* Video play icon — subtle at rest, visible on hover */} - {image.media_kind === "video" && ( -
-
- - - -
-
- )} - - {/* Persistent badges — only shown when meaningful */} -
- {image.embedding_status === "failed" && ( - -
- - - -
-
- )} - {image.favorite && ( -
- - - -
- )} - {image.rating > 0 && ( -
- {Array.from({ length: image.rating }, (_, index) => ( - - - - ))} -
- )} - {image.media_kind === "video" && image.duration_ms && ( -
- {formatDuration(image.duration_ms)} -
- )} -
- - {/* Hover overlay — slides up from bottom */} -
- - {/* Hover info — appears with overlay */} -
- -
- {image.rating > 0 ? ( -
- {Array.from({ length: image.rating }, (_, i) => ( - - - - ))} -
- ) : ( - - )} - -
-
-
- ); -} - -function TruncatedFilename({ filename }: { filename: string }) { - const textRef = useRef(null); - const [isTruncated, setIsTruncated] = useState(false); - - useLayoutEffect(() => { - const text = textRef.current; - if (!text) return; - - const update = () => { - setIsTruncated(text.scrollWidth > text.clientWidth); - }; - - update(); - - const observer = new ResizeObserver(update); - observer.observe(text); - return () => observer.disconnect(); - }, [filename]); - - const label = ( -

- {filename} -

- ); - - return ( - - {label} - - ); -} +const GAP = 6 export function Gallery() { - const images = useGalleryStore((state) => state.images); - const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); - const openImage = useGalleryStore((state) => state.openImage); - const totalImages = useGalleryStore((state) => state.totalImages); - const loadingImages = useGalleryStore((state) => state.loadingImages); - const zoomPreset = useGalleryStore((state) => state.zoomPreset); - const search = useGalleryStore((state) => state.search); - const collectionTitle = useGalleryStore((state) => state.collectionTitle); - const imageLoadError = useGalleryStore((state) => state.imageLoadError); - const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); - const isSimilarResults = collectionTitle === "Similar Images"; - const parsedSearch = parseSearchValue(search); + const images = useGalleryStore((state) => state.images) + const loadMoreImages = useGalleryStore((state) => state.loadMoreImages) + const openImage = useGalleryStore((state) => state.openImage) + const totalImages = useGalleryStore((state) => state.totalImages) + const loadingImages = useGalleryStore((state) => state.loadingImages) + const zoomPreset = useGalleryStore((state) => state.zoomPreset) + const search = useGalleryStore((state) => state.search) + const collectionTitle = useGalleryStore((state) => state.collectionTitle) + const imageLoadError = useGalleryStore((state) => state.imageLoadError) + const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey) + const isSimilarResults = collectionTitle === 'Similar Images' + const parsedSearch = parseSearchValue(search) - const parentRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); + const parentRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + const [contextMenu, setContextMenu] = useState<{ + x: number + y: number + image: ImageRecord + } | null>(null) useLayoutEffect(() => { - const el = parentRef.current; - if (!el) return; - setContainerWidth(el.clientWidth); + const el = parentRef.current + if (!el) return + setContainerWidth(el.clientWidth) const ro = new ResizeObserver((entries) => { - setContainerWidth(entries[0].contentRect.width); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); + setContainerWidth(entries[0].contentRect.width) + }) + ro.observe(el) + return () => ro.disconnect() + }, []) - const tileSize = tileSizeForZoom(zoomPreset); + const tileSize = tileSizeForZoom(zoomPreset) const cols = useMemo( () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), - [containerWidth, tileSize], - ); - const rowCount = Math.ceil(images.length / cols); + [containerWidth, tileSize] + ) + const rowCount = Math.ceil(images.length / cols) - const estimateSize = useCallback(() => tileSize + GAP, [tileSize]); + const estimateSize = useCallback(() => tileSize + GAP, [tileSize]) const virtualizer = useVirtualizer({ count: rowCount, @@ -368,165 +56,105 @@ export function Gallery() { estimateSize, overscan: 3, paddingStart: GAP, - }); + }) useEffect(() => { - virtualizer.measure(); - }, [cols, virtualizer]); + virtualizer.measure() + }, [cols, virtualizer]) useEffect(() => { - parentRef.current?.scrollTo({ top: 0, left: 0 }); - }, [galleryScrollResetKey]); + parentRef.current?.scrollTo({ top: 0, left: 0 }) + }, [galleryScrollResetKey]) const handleScroll = useCallback(() => { - const el = parentRef.current; - if (!el) return; - if (el.scrollTop < 24) return; - const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; + const el = parentRef.current + if (!el) return + if (el.scrollTop < 24) return + const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600 if (nearBottom && !loadingImages && images.length < totalImages) { - void loadMoreImages(); + void loadMoreImages() } - }, [images.length, loadMoreImages, loadingImages, totalImages]); + }, [images.length, loadMoreImages, loadingImages, totalImages]) useEffect(() => { - const el = parentRef.current; - if (!el) return; - el.addEventListener("scroll", handleScroll, { passive: true }); - 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); - }; - }, []); + const el = parentRef.current + if (!el) return + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => el.removeEventListener('scroll', handleScroll) + }, [handleScroll]) return ( -
-
- {images.length === 0 && loadingImages ? ( -
-
-
-

- {isSimilarResults - ? "Finding similar images" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? `Searching for matches to "${parsedSearch.query}"` - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? `Searching tags for "${parsedSearch.query}"` - : "Loading media"} -

-

- {isSimilarResults - ? "Comparing visual embeddings" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "Semantic search can take a little longer than filename search" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "Matching against AI and user tags" - : "Fetching results"} -

+
+
+ {images.length === 0 && loadingImages ? ( + + ) : images.length === 0 && !loadingImages ? ( + + ) : ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * cols + const rowImages = images.slice(startIndex, startIndex + cols) + return ( +
+ {rowImages.map((image) => ( + openImage(image)} + onContextMenu={(event) => { + event.preventDefault() + setContextMenu({ x: event.clientX, y: event.clientY, image }) + }} + /> + ))} +
+ ) + })}
-
- ) : images.length === 0 && !loadingImages ? ( -
-
- - - -

- {imageLoadError - ? "Could not load results" - : isSimilarResults - ? "No similar images found" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "No semantic matches found" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "No tag matches found" - : "No media found"} -

-

- {imageLoadError - ? imageLoadError - : isSimilarResults - ? "This item may be visually isolated, or more embeddings may need to finish processing" - : parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 - ? "Try a broader phrase, or wait for more embeddings to finish processing" - : parsedSearch.mode === "tag" && parsedSearch.query.length > 0 - ? "Try a shorter tag, or wait for more tagging jobs to finish" - : "Try adjusting your filters or add a new folder"} -

+ )} + + {images.length > 0 && loadingImages ? ( +
+
-
- ) : ( -
- {virtualizer.getVirtualItems().map((virtualRow) => { - const startIndex = virtualRow.index * cols; - const rowImages = images.slice(startIndex, startIndex + cols); - return ( -
- {rowImages.map((image) => ( - openImage(image)} - onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); - }} - /> - ))} -
- ); - })} -
- )} + ) : null} - {images.length > 0 && loadingImages ? ( -
-
-
- ) : null} + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} +
- {contextMenu ? ( - setContextMenu(null)} - /> - ) : null} + {/* Pinned to the bottom of the gallery viewport — outside the scroll + container so it stays put while the grid scrolls. */} +
- - {/* Pinned to the bottom of the gallery viewport — outside the scroll - container so it stays put while the grid scrolls. */} - -
- ); + ) } diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx new file mode 100644 index 0000000..1c09d13 --- /dev/null +++ b/src/components/ImageContextMenu.tsx @@ -0,0 +1,88 @@ +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({ + 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 albums = useGalleryStore((state) => state.albums) + const addToAlbum = useGalleryStore((state) => state.addToAlbum) + const canFindSimilar = image.embedding_status === 'ready' + + return ( + + openImage(image)} /> + void updateImageDetails(image.id, { favorite: !image.favorite })} + /> + findSimilar(image.id, image.folder_id)} + /> + + {albums.length === 0 ? ( + + ) : ( + albums.map((album) => ( + void addToAlbum(album.id, [image.id])} + /> + )) + )} + + + Rating +
+ {Array.from({ length: 5 }, (_, index) => { + const rating = index + 1 + return ( + + + + ) + })} + {image.rating > 0 ? ( + + + + ) : null} +
+
+ ) +} diff --git a/src/components/InlineConfirm.tsx b/src/components/InlineConfirm.tsx new file mode 100644 index 0000000..2396bc7 --- /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..e6fb827 --- /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/Lightbox.tsx b/src/components/Lightbox.tsx index 22c4730..d700aad 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,674 +1,125 @@ -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"; - -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); - const closeImage = useGalleryStore((state) => state.closeImage); - const images = useGalleryStore((state) => state.images); - const openImage = useGalleryStore((state) => state.openImage); - const findSimilar = useGalleryStore((state) => state.findSimilar); - const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion); - const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); - const getImageTags = useGalleryStore((state) => state.getImageTags); - const addUserTag = useGalleryStore((state) => state.addUserTag); - const removeTag = useGalleryStore((state) => state.removeTag); - const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); - const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus); - const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage); - const albums = useGalleryStore((state) => state.albums); - const addToAlbum = useGalleryStore((state) => state.addToAlbum); - const createAlbum = useGalleryStore((state) => state.createAlbum); - const getImageExif = useGalleryStore((state) => state.getImageExif); - const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); - const loadedCount = useGalleryStore((state) => state.loadedCount); - const totalImages = useGalleryStore((state) => state.totalImages); - const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds); - const slideshowOrder = useGalleryStore((state) => state.slideshowOrder); - const slideshowTransition = useGalleryStore((state) => state.slideshowTransition); + const selectedImage = useGalleryStore((state) => state.selectedImage) + const closeImage = useGalleryStore((state) => state.closeImage) + const images = useGalleryStore((state) => state.images) + const openImage = useGalleryStore((state) => state.openImage) + const findSimilar = useGalleryStore((state) => state.findSimilar) + const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion) + const updateImageDetails = useGalleryStore((state) => state.updateImageDetails) + const getImageTags = useGalleryStore((state) => state.getImageTags) + const addUserTag = useGalleryStore((state) => state.addUserTag) + const removeTag = useGalleryStore((state) => state.removeTag) + const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus) + const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus) + const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage) + const albums = useGalleryStore((state) => state.albums) + const addToAlbum = useGalleryStore((state) => state.addToAlbum) + const createAlbum = useGalleryStore((state) => state.createAlbum) + const getImageExif = useGalleryStore((state) => state.getImageExif) + const loadMoreImages = useGalleryStore((state) => state.loadMoreImages) + const loadedCount = useGalleryStore((state) => state.loadedCount) + const totalImages = useGalleryStore((state) => state.totalImages) + const slideshowIntervalSeconds = useGalleryStore((state) => state.slideshowIntervalSeconds) + 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 lightboxRootRef = useRef(null) + const imageViewportRef = useRef(null) + const imgRef = useRef(null) + const [view, setView] = useState(IDENTITY_VIEW) - 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 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; - const taggerStatusKnown = taggerModelStatus !== null; + const currentIndex = selectedImage + ? images.findIndex((image) => image.id === selectedImage.id) + : -1 + const canFindSimilar = selectedImage?.embedding_status === 'ready' + const canSearchRegion = canFindSimilar && selectedImage?.media_kind === 'image' + const taggerReady = taggerModelStatus?.ready ?? false + const taggerStatusKnown = taggerModelStatus !== null const taggerButtonTooltip = !taggerStatusKnown - ? "Checking AI tagger model..." + ? 'Checking AI tagger model...' : taggerReady - ? "Queue AI tagging for this image" - : "AI tagger model not installed"; + ? '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 ( @@ -677,763 +128,108 @@ 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} + /> )} ) : null} - ); + ) } diff --git a/src/components/MenuBar.tsx b/src/components/MenuBar.tsx deleted file mode 100644 index a48dbac..0000000 --- a/src/components/MenuBar.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { MediaFilter, ZoomPreset, useGalleryStore } from "../store"; - -type MenuKey = "library" | "view" | "filter"; - -function MenuButton({ - label, - active, - onClick, -}: { - label: string; - active: boolean; - onClick: () => void; -}) { - 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} -
-
- ); -} diff --git a/src/components/PhokusMark.tsx b/src/components/PhokusMark.tsx index 627267a..2359d94 100644 --- a/src/components/PhokusMark.tsx +++ b/src/components/PhokusMark.tsx @@ -6,14 +6,14 @@ // // Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point — // used in the titlebar as the "update available" indicator. -const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66"; +const BLADE = 'M0,-4.18 A10,10 0 0 1 6.43,-7.66' export function PhokusMark({ className, dotClassName, }: { - className?: string; - dotClassName?: string; + className?: string + dotClassName?: string }) { return ( @@ -33,7 +33,9 @@ export function PhokusMark({ - {dotClassName ? : null} + {dotClassName ? ( + + ) : null} - ); + ) } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3a38264..c4c0c44 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,414 +1,80 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, SlideshowOrder, SlideshowTransition, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; -import { FfmpegStatusRow } from "./onboarding/StepWelcome"; -import { ThemedDropdown } from "./ThemedDropdown"; -import { getChangelogForVersion } from "../changelog"; -import { Tooltip } from "./Tooltip"; -import { TAGGER_MODELS } from "../taggerModels"; +import { useEffect, useState } from 'react' +import { useGalleryStore } from '../store' +import { Tooltip } from './Tooltip' +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 [activeSection, setActiveSection] = useState('general') - 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); + const settingsOpen = useGalleryStore((state) => state.settingsOpen) + const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen) + const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope) + const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds) + const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus) + const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration) + const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel) + const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold) + const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize) useEffect(() => { - if (!settingsOpen) return; - void loadTaggerModelStatus(); - void loadTaggerModel(); - void loadTaggerAcceleration(); - void loadTaggerThreshold(); - void loadTaggerBatchSize(); - void loadTaggingQueueScope(); - void loadTaggingQueueFolderIds(); + if (!settingsOpen) return + void loadTaggerModelStatus() + void loadTaggerModel() + void loadTaggerAcceleration() + void loadTaggerThreshold() + void loadTaggerBatchSize() + void loadTaggingQueueScope() + void loadTaggingQueueFolderIds() const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setSettingsOpen(false); - }; - 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], - ); - - 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); + if (event.key === 'Escape') setSettingsOpen(false) } - setTaggerQueueStatus(null); - setTaggerResetConfirming(false); + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [ + settingsOpen, + loadTaggerModelStatus, + loadTaggerModel, + loadTaggerAcceleration, + loadTaggerThreshold, + loadTaggerBatchSize, + loadTaggingQueueScope, + loadTaggingQueueFolderIds, + setSettingsOpen, + ]) - 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); - } - }); - }; + if (!settingsOpen) return null - 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)}> +
setSettingsOpen(false)} + >
event.stopPropagation()} @@ -416,14 +82,15 @@ export function SettingsModal() { -
+
@@ -451,738 +116,10 @@ export function SettingsModal() {

{activeSectionMeta.label}

{activeSectionMeta.detail}

- - {activeSection === "workspace" ? ( -
- - -
-
- {(["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} -
- - - - - - -
- ) : ( -
- {activeSection === "general" ? ( - <> - - - setTheme(value as AppTheme)} - ariaLabel="App theme" - options={[ - { value: "phokus", label: "Phokus" }, - { value: "subtle-light", label: "Subtle Light" }, - { value: "conventional-dark", label: "Conventional Dark" }, - ]} - /> - - - - - - - - - - - - - ) : null} - - {activeSection === "media" ? ( - <> - - - - - - - - - - - -
- setSlideshowIntervalSeconds(Number(event.currentTarget.value))} - /> - {slideshowIntervalSeconds}s -
-
- - setSlideshowOrder(value as SlideshowOrder)} - ariaLabel="Slideshow order" - options={[ - { value: "sequential", label: "Sequential" }, - { value: "random", label: "Random" }, - ]} - /> - - - setSlideshowTransition(value as SlideshowTransition)} - ariaLabel="Slideshow transition" - options={[ - { value: "soft-fade", label: "Soft fade" }, - { value: "gentle-motion", label: "Gentle motion" }, - ]} - /> - -
- - - ) : 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/Sidebar.tsx b/src/components/Sidebar.tsx index cfe87cc..b1f26fc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,1133 +1,60 @@ -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 { 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, - 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(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(); - 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 }); - }; - - 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); - } - }; - - 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 && selectFolder(folder.id)} - onContextMenu={handleContextMenu} - > - {customOrdering ? ( - - - - ) : null} - {isMissing ? ( - - - - - - ) : ( - - - - )} - -
- {renaming ? ( - setRenameValue(e.target.value)} - onKeyDown={handleRenameKey} - onBlur={() => void commitRename()} - onClick={(e) => e.stopPropagation()} - /> - ) : ( -
- {folder.name} -
- )} - {isIndexing ? ( - <> -
{progress.indexed}/{progress.total}
-
-
0 ? (progress.indexed / progress.total) * 100 : 0}%` }} - /> -
- - ) : ( -
{folder.image_count.toLocaleString()}
- )} -
- - {/* Hover action buttons */} - {!renaming && ( - confirmingRemoval ? ( -
e.stopPropagation()}> - - -
- ) : ( -
- - - - - - -
- ) - )} -
- - {isMissing && ( -
-

Folder not found

-

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

-
- - -
-
- )} - - {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)} - /> - )} - - ); -} - -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, - 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 [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); - - 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: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) }); - }} - > - {/* Manage-mode selection checkbox */} - {manageMode ? ( -
- - - -
- ) : null} - - {/* Drag handle — hover-revealed, reorders albums */} - {reorderable ? ( - - - - ) : null} - - {/* Cover thumbnail — distinguishes albums from folder rows */} -
- {cover ? ( - - ) : ( -
- - - -
- )} -
- -
- {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()} - /> - ) : ( -
- {album.name} -
- )} -
{album.image_count.toLocaleString()}
-
- - {!renaming && confirmingRemoval ? ( -
e.stopPropagation()}> - - -
- ) : null} - - {menu ? ( - setMenu(null)} - onRename={() => setRenaming(true)} - onDelete={() => setConfirmingRemoval(true)} - /> - ) : null} -
- ); - - if (reorderable) { - return ( - - {row} - - ); - } - return row; -} +import { useGalleryStore } from '../store' +import { Tooltip } from './Tooltip' +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 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(); - }; + const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen) + const selectedFolderId = useGalleryStore((state) => state.selectedFolderId) + const selectFolder = useGalleryStore((state) => state.selectFolder) + const activeView = useGalleryStore((state) => state.activeView) + const setView = useGalleryStore((state) => state.setView) return ( -