From 827e1a8ecf26b6fccc51b9e5af05894308049ed5 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Sat, 4 Jul 2026 20:23:32 +0100 Subject: [PATCH] style: format frontend with prettier Mechanical one-shot pass of pnpm format over src/, tests/, tools/, and root configs. No functional changes; build and type-check verified. --- playwright.config.ts | 4 +- src/App.tsx | 138 +-- src/changelog.ts | 106 +-- src/components/AlbumPicker.tsx | 34 +- src/components/BackgroundTasks.tsx | 128 +-- src/components/BulkActionBar.tsx | 124 +-- src/components/ColorFilter.tsx | 188 ++-- src/components/DemoPanel.tsx | 156 ++-- src/components/DuplicateFinder.tsx | 98 +- src/components/ExploreView.tsx | 140 +-- src/components/FolderPickerModal.tsx | 102 ++- src/components/FolderScopeDropdown.tsx | 34 +- src/components/Gallery.tsx | 135 +-- src/components/ImageContextMenu.tsx | 54 +- src/components/InlineConfirm.tsx | 12 +- src/components/InlineRename.tsx | 38 +- src/components/Lightbox.tsx | 132 +-- src/components/PhokusMark.tsx | 12 +- src/components/SettingsModal.tsx | 104 ++- src/components/Sidebar.tsx | 58 +- src/components/Timeline.tsx | 191 ++-- src/components/TitleBar.tsx | 155 ++-- src/components/Toolbar.tsx | 16 +- src/components/Tooltip.tsx | 161 ++-- src/components/UpdateToast.tsx | 36 +- src/components/VideoPlayer.tsx | 22 +- src/components/WhatsNewModal.tsx | 110 +-- src/components/WhatsNewToast.tsx | 18 +- .../backgroundTasks/BackgroundTaskActions.tsx | 59 +- .../backgroundTasks/BackgroundTaskSummary.tsx | 58 +- .../backgroundTasks/ExpandedTaskPanel.tsx | 50 +- .../backgroundTasks/FailedWorkerItemRow.tsx | 44 +- .../backgroundTasks/TaskProgressBar.tsx | 18 +- .../backgroundTasks/TaskStagePill.tsx | 74 +- src/components/backgroundTasks/taskModel.ts | 207 +++-- src/components/backgroundTasks/types.ts | 52 +- src/components/bulk/BulkAlbumPopover.tsx | 6 +- src/components/bulk/BulkDeleteConfirm.tsx | 25 +- src/components/bulk/BulkRatingPopover.tsx | 22 +- src/components/bulk/BulkSelectionSummary.tsx | 25 +- src/components/bulk/BulkTagFields.tsx | 22 +- src/components/bulk/BulkTagPopover.tsx | 15 +- src/components/bulk/types.ts | 2 +- src/components/bulk/useBulkTagEditor.ts | 90 +- .../DuplicateFinderEmptyStates.tsx | 27 +- .../duplicateFinder/DuplicateFinderHeader.tsx | 99 +- .../duplicateFinder/DuplicateGroupCard.tsx | 62 +- src/components/duplicateFinder/format.ts | 30 +- src/components/explore/ClusterCloud.tsx | 335 ++++--- .../explore/ExploreLoadingPanel.tsx | 22 +- src/components/explore/TagAtlas.tsx | 366 ++++---- src/components/explore/TagManageList.tsx | 301 ++++--- src/components/explore/layout.ts | 50 +- src/components/folderPicker/FolderRow.tsx | 44 +- .../folderPicker/StagedFoldersPanel.tsx | 40 +- src/components/folderPicker/StatusLine.tsx | 14 +- src/components/folderPicker/pathUtils.ts | 67 +- .../folderPicker/useFolderPicker.ts | 247 ++--- src/components/gallery/GalleryEmptyState.tsx | 64 +- src/components/gallery/ImageTile.tsx | 96 +- src/components/gallery/TruncatedFilename.tsx | 32 +- src/components/gallery/format.ts | 14 +- src/components/icons.tsx | 56 +- src/components/lightbox/LightboxAlbumMenu.tsx | 62 +- .../lightbox/LightboxDetailsPanel.tsx | 304 ++++--- src/components/lightbox/LightboxNavButton.tsx | 20 +- src/components/lightbox/LightboxViewport.tsx | 138 +-- src/components/lightbox/SlideshowView.tsx | 85 +- src/components/lightbox/format.ts | 68 +- src/components/lightbox/types.ts | 30 +- .../lightbox/useLightboxMediaDetails.ts | 102 ++- .../lightbox/useLightboxNavigation.ts | 112 +-- src/components/lightbox/useRegionSelection.ts | 197 ++-- src/components/lightbox/useSlideshow.ts | 287 +++--- src/components/lightbox/viewTransform.ts | 68 +- src/components/menu/ContextMenu.tsx | 52 +- src/components/menu/Dropdown.tsx | 92 +- src/components/menu/Menu.tsx | 181 ++-- src/components/menu/index.ts | 12 +- src/components/menu/useDismissable.ts | 28 +- .../onboarding/OnboardingOverlay.tsx | 96 +- src/components/onboarding/StepAddLibrary.tsx | 29 +- src/components/onboarding/StepAiFeatures.tsx | 146 +-- .../onboarding/StepGalleryPreview.tsx | 41 +- src/components/onboarding/StepPipeline.tsx | 78 +- src/components/onboarding/StepSearchDemo.tsx | 100 ++- src/components/onboarding/StepUpdates.tsx | 43 +- src/components/onboarding/StepViews.tsx | 40 +- src/components/onboarding/StepWelcome.tsx | 187 ++-- src/components/onboarding/fakes.tsx | 155 ++-- .../settings/AiWorkspaceSettingsSection.tsx | 547 ++++++----- .../settings/GeneralSettingsSection.tsx | 43 +- .../settings/MediaSettingsSection.tsx | 52 +- .../settings/StorageSettingsSection.tsx | 146 +-- .../settings/UpdatesSettingsSection.tsx | 82 +- src/components/settings/shared.tsx | 190 ++-- src/components/sidebar/AlbumItem.tsx | 154 ++-- src/components/sidebar/AlbumSection.tsx | 175 ++-- src/components/sidebar/FolderItem.tsx | 275 +++--- src/components/sidebar/LibrarySection.tsx | 40 +- src/components/sidebar/NavItem.tsx | 20 +- src/components/sidebar/types.ts | 4 +- src/components/sidebar/useAlbumOrdering.ts | 48 +- src/components/sidebar/useFolderOrdering.ts | 153 ++-- src/components/timeline/ScrubberYearBlock.tsx | 36 +- .../timeline/TimelineEmptyState.tsx | 8 +- src/components/timeline/timelineModel.ts | 95 +- src/components/timeline/types.ts | 29 +- src/components/toolbar/FilterPill.tsx | 22 +- src/components/toolbar/SortControl.tsx | 22 +- src/components/toolbar/ToolbarFilters.tsx | 132 +-- src/components/toolbar/ToolbarSearch.tsx | 60 +- src/components/toolbar/ToolbarTitle.tsx | 34 +- src/components/toolbar/ZoomControl.tsx | 28 +- src/components/toolbar/searchValue.ts | 18 +- src/components/toolbar/sortOptions.ts | 36 +- src/components/toolbar/useToolbarSearch.ts | 187 ++-- src/components/videoPlayer/ControlButton.tsx | 20 +- src/components/videoPlayer/VideoControls.tsx | 184 ++-- src/components/videoPlayer/constants.ts | 8 +- src/components/videoPlayer/format.ts | 14 +- src/components/videoPlayer/playbackPrefs.ts | 12 +- src/components/videoPlayer/types.ts | 4 +- src/components/videoPlayer/useVideoPlayer.ts | 327 +++---- src/dev/applyMockScenario.ts | 20 +- src/dev/mockBackend.ts | 848 ++++++++++-------- src/dev/mockFixtures.ts | 595 ++++++------ src/dev/mockMedia.ts | 6 +- src/dev/mockScenarios.ts | 48 +- src/dev/setupMockTauri.ts | 18 +- src/index.css | 131 ++- src/lib/mediaSrc.ts | 16 +- src/main.tsx | 20 +- src/notifications.ts | 22 +- src/store/albumSlice.ts | 171 ++-- src/store/appSlice.ts | 266 +++--- src/store/captionSlice.ts | 202 +++-- src/store/duplicateSlice.ts | 143 +-- src/store/events.ts | 368 ++++---- src/store/exploreSlice.ts | 240 ++--- src/store/gallerySlice.ts | 507 ++++++----- src/store/helpers.ts | 265 +++--- src/store/index.ts | 42 +- src/store/librarySlice.ts | 196 ++-- src/store/searchSlice.ts | 290 ++++-- src/store/settingsSlice.ts | 169 ++-- src/store/taggerSlice.ts | 301 ++++--- src/store/types.ts | 428 ++++----- src/taggerModels.ts | 21 +- tests/example.spec.ts | 16 +- tools/changelog-add.mjs | 87 +- vite.config.ts | 14 +- 152 files changed, 9204 insertions(+), 7445 deletions(-) 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/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 index 9b95445..4046f68 100644 --- a/src/components/AlbumPicker.tsx +++ b/src/components/AlbumPicker.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useGalleryStore } from "../store"; +import { useState } from 'react' +import { useGalleryStore } from '../store' /** * Album list plus a create-new-album form. The host decides what picking @@ -7,23 +7,23 @@ import { useGalleryStore } from "../store"; * 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 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); + const name = newAlbumName.trim() + if (!name || creating) return + setCreating(true) try { - const album = await createAlbum(name); - setNewAlbumName(""); - await onPick(album.id); + const album = await createAlbum(name) + setNewAlbumName('') + await onPick(album.id) } finally { - setCreating(false); + setCreating(false) } - }; + } return ( <> @@ -46,8 +46,8 @@ export function AlbumPicker({ onPick }: { onPick: (albumId: number) => Promise { - event.preventDefault(); - void handleCreate(); + event.preventDefault() + void handleCreate() }} > Promise - ); + ) } diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index 555168e..7e26e44 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -1,68 +1,83 @@ -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"; +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 workerPaused = useGalleryStore((state) => state.workerPaused); - const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); - const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); - const [expanded, setExpanded] = useState(false); - const [dismissed, setDismissed] = useState>({}); - const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); - const [failedTaggingItems, setFailedTaggingItems] = useState>({}); + const 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]) const failedEmbeddingCounts = useMemo( () => Object.fromEntries( - Object.entries(mediaJobProgress).map(([id, progress]) => [id, progress?.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, progress]) => [id, progress?.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 folderTasks = useMemo( () => @@ -73,37 +88,38 @@ export function BackgroundTasks() { mediaJobProgress, workerPaused, }), - [dismissed, folders, indexingProgress, mediaJobProgress, workerPaused], - ); + [dismissed, folders, indexingProgress, mediaJobProgress, workerPaused] + ) const duplicateScanTask = useMemo( () => buildDuplicateScanTask(duplicateScanning, duplicateScanProgress), - [duplicateScanning, duplicateScanProgress], - ); - const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks; + [duplicateScanning, duplicateScanProgress] + ) + const allTasks = duplicateScanTask ? [duplicateScanTask, ...folderTasks] : folderTasks - if (allTasks.length === 0) return null; + if (allTasks.length === 0) return null - const isWorkerPaused = (folderId: number, worker: WorkerKey) => workerPaused[folderId]?.[worker] ?? false; + 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 = (task: BackgroundTask) => { - if (task.id < 0) return; - setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot })); - setExpanded(false); - }; + if (task.id < 0) return + setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot })) + setExpanded(false) + } const retryTask = (task: BackgroundTask) => { - if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); - if (task.hasFailedTagging) void queueTaggingJobs(task.id); - }; + if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id) + if (task.hasFailedTagging) void queueTaggingJobs(task.id) + } - const primary = allTasks[0]; - const hasFailed = folderTasks.some(taskHasTerminalFailure); - const barProgress = taskProgress(primary); + const primary = allTasks[0] + const hasFailed = folderTasks.some(taskHasTerminalFailure) + const barProgress = taskProgress(primary) return (
@@ -135,5 +151,5 @@ export function BackgroundTasks() { /> ) : null}
- ); + ) } diff --git a/src/components/BulkActionBar.tsx b/src/components/BulkActionBar.tsx index 699c1e3..9f808f3 100644 --- a/src/components/BulkActionBar.tsx +++ b/src/components/BulkActionBar.tsx @@ -1,66 +1,67 @@ -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"; +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 addToAlbum = useGalleryStore((state) => state.addToAlbum); - const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum); + 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 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 or pressing Escape. - useDismissable(barRef, () => setPanel(null), panel !== null); + useDismissable(barRef, () => setPanel(null), panel !== null) // Reset transient UI whenever the selection empties. useEffect(() => { - if (selectedCount === 0) setPanel(null); - }, [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 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 (
- - {panel === "tag" ? setPanel(null)} /> : null} + {panel === 'tag' ? setPanel(null)} /> : null}
- - {panel === "rating" ? setPanel(null)} /> : null} + {panel === 'rating' ? ( + setPanel(null)} /> + ) : null}
- - {panel === "album" ? : null} + {panel === 'album' ? : null}
{inAlbumView ? ( @@ -117,17 +129,17 @@ export function BulkActionBar() { - {panel === "delete" ? ( + {panel === 'delete' ? (
- ); + ) } diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx index bdb80f5..509ae35 100644 --- a/src/components/ColorFilter.tsx +++ b/src/components/ColorFilter.tsx @@ -1,69 +1,82 @@ -import { useRef, useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { useGalleryStore } from "../store"; -import { useDismissable } from "./menu"; -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 or pressing Escape. - useDismissable(ref, () => setOpen(false), open); + 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}
@@ -153,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 c0c2ad6..4315ee0 100644 --- a/src/components/DuplicateFinder.tsx +++ b/src/components/DuplicateFinder.tsx @@ -1,58 +1,65 @@ -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"; +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 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 progressLabel = duplicateProgressLabel(duplicateScanProgress); + const progressLabel = duplicateProgressLabel(duplicateScanProgress) return (
@@ -72,8 +79,8 @@ export function DuplicateFinder() { onConfirmDelete={() => setConfirmingDelete(false)} onDelete={handleDelete} onScan={() => { - setDeleteResult(null); - void scanDuplicates(selectedFolderId); + setDeleteResult(null) + void scanDuplicates(selectedFolderId) }} onSelectKeepFirstAll={selectKeepFirstAllGroups} selectedCount={selectedCount} @@ -88,32 +95,31 @@ export function DuplicateFinder() { ) : (
-
+
{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 fe89011..31db42d 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -1,51 +1,55 @@ -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"; +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 (
@@ -57,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}
@@ -112,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' ? (
@@ -133,8 +143,12 @@ export function ExploreView() { />
) : ( - + )}
- ); + ) } diff --git a/src/components/FolderPickerModal.tsx b/src/components/FolderPickerModal.tsx index 9b1333b..9b0d09a 100644 --- a/src/components/FolderPickerModal.tsx +++ b/src/components/FolderPickerModal.tsx @@ -1,23 +1,23 @@ -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"; +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 folderPicker = useFolderPicker(); + const folderPicker = useFolderPicker() const virtualizer = useVirtualizer({ count: folderPicker.entries.length, getScrollElement: () => folderPicker.scrollRef.current, estimateSize: () => 48, overscan: 8, - }); + }) - if (!folderPicker.folderPickerOpen) return null; + if (!folderPicker.folderPickerOpen) return null return (
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. +

) : ( -
+
{folderPicker.error ? ( -
+
{folderPicker.error}
) : null} -
+
{folderPicker.loading ? ( -
Loading folders...
+
+ Loading folders... +
) : folderPicker.entries.length === 0 ? ( -
No folders found here.
+
+ No folders found here. +
) : (
{virtualizer.getVirtualItems().map((virtualItem) => { - const entry = folderPicker.entries[virtualItem.index]; - const normalized = normalizePath(entry.path); + const entry = folderPicker.entries[virtualItem.index] + const normalized = normalizePath(entry.path) return (
folderPicker.setCurrentPath(entry.path)} />
- ); + ) })}
)} @@ -178,7 +192,7 @@ export function FolderPickerModal() { />
-
+
@@ -187,23 +201,23 @@ export function FolderPickerModal() {
- ); + ) } diff --git a/src/components/FolderScopeDropdown.tsx b/src/components/FolderScopeDropdown.tsx index c2de0c2..59da2a1 100644 --- a/src/components/FolderScopeDropdown.tsx +++ b/src/components/FolderScopeDropdown.tsx @@ -1,6 +1,6 @@ -import { useMemo } from "react"; -import { useGalleryStore } from "../store"; -import { Dropdown, DropdownOption } from "./menu"; +import { useMemo } from 'react' +import { useGalleryStore } from '../store' +import { Dropdown, DropdownOption } from './menu' /** * In-view folder scope picker for feature views (Timeline / Explore / @@ -8,21 +8,21 @@ import { Dropdown, DropdownOption } from "./menu"; * current view active — unlike sidebar folder clicks, which jump to Gallery. */ export function FolderScopeDropdown() { - 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) const options = useMemo[]>( () => [ - { value: null, label: "All Media" }, + { value: null, label: 'All Media' }, ...folders.map((folder) => ({ value: folder.id, label: folder.name, hint: {folder.image_count.toLocaleString()}, })), ], - [folders], - ); + [folders] + ) return ( - + + } /> - ); + ) } diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 2c8a8eb..26252d5 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -1,50 +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 { ImageContextMenu } from "./ImageContextMenu"; -import { GalleryEmptyState, GalleryLoadingState } from "./gallery/GalleryEmptyState"; -import { ImageTile } from "./gallery/ImageTile"; +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; +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, @@ -52,36 +56,39 @@ 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]); + const el = parentRef.current + if (!el) return + el.addEventListener('scroll', handleScroll, { passive: true }) + return () => el.removeEventListener('scroll', handleScroll) + }, [handleScroll]) return (
-
+
{images.length === 0 && loadingImages ? ( ) : images.length === 0 && !loadingImages ? ( @@ -91,25 +98,25 @@ export function Gallery() { parsedSearch={parsedSearch} /> ) : ( -
+
{virtualizer.getVirtualItems().map((virtualRow) => { - const startIndex = virtualRow.index * cols; - const rowImages = images.slice(startIndex, startIndex + cols); + const startIndex = virtualRow.index * cols + const rowImages = images.slice(startIndex, startIndex + cols) return (
{rowImages.map((image) => ( @@ -118,13 +125,13 @@ export function Gallery() { image={image} onClick={() => openImage(image)} onContextMenu={(event) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, image }); + event.preventDefault() + setContextMenu({ x: event.clientX, y: event.clientY, image }) }} /> ))}
- ); + ) })}
)} @@ -149,5 +156,5 @@ export function Gallery() { container so it stays put while the grid scrolls. */}
- ); + ) } diff --git a/src/components/ImageContextMenu.tsx b/src/components/ImageContextMenu.tsx index 086ea79..1c09d13 100644 --- a/src/components/ImageContextMenu.tsx +++ b/src/components/ImageContextMenu.tsx @@ -1,7 +1,7 @@ -import { ImageRecord, useGalleryStore } from "../store"; -import { ContextMenu, MenuItem, MenuLabel, MenuSeparator, SubMenu } from "./menu"; -import { Tooltip } from "./Tooltip"; -import { CloseIcon, StarIcon } from "./icons"; +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({ @@ -10,27 +10,27 @@ export function ImageContextMenu({ image, onClose, }: { - x: number; - y: number; - image: ImageRecord; - onClose: () => void; + 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"; + 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)} /> @@ -52,23 +52,31 @@ export function ImageContextMenu({ Rating
{Array.from({ length: 5 }, (_, index) => { - const rating = index + 1; + const rating = index + 1 return ( - ); + ) })} {image.rating > 0 ? ( @@ -76,5 +84,5 @@ export function ImageContextMenu({ ) : null}
- ); + ) } diff --git a/src/components/InlineConfirm.tsx b/src/components/InlineConfirm.tsx index 7e4b5f4..2396bc7 100644 --- a/src/components/InlineConfirm.tsx +++ b/src/components/InlineConfirm.tsx @@ -6,23 +6,23 @@ export function InlineConfirm({ onConfirm, onCancel, }: { - onConfirm: () => void; - onCancel: () => void; + onConfirm: () => void + onCancel: () => void }) { return ( -
event.stopPropagation()}> +
event.stopPropagation()}>
- ); + ) } diff --git a/src/components/InlineRename.tsx b/src/components/InlineRename.tsx index ce873da..e6fb827 100644 --- a/src/components/InlineRename.tsx +++ b/src/components/InlineRename.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from 'react' /** * In-place rename input for sidebar rows (folders, albums). Mount it in @@ -10,41 +10,41 @@ export function InlineRename({ onRename, onClose, }: { - name: string; - onRename: (next: string) => Promise | void; - onClose: () => void; + name: string + onRename: (next: string) => Promise | void + onClose: () => void }) { - const [value, setValue] = useState(name); - const inputRef = useRef(null); + const [value, setValue] = useState(name) + const inputRef = useRef(null) useEffect(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, []); + inputRef.current?.focus() + inputRef.current?.select() + }, []) const commit = async () => { - const trimmed = value.trim(); + const trimmed = value.trim() if (trimmed && trimmed !== name) { - await onRename(trimmed); + await onRename(trimmed) } - onClose(); - }; + onClose() + } return ( setValue(event.target.value)} onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void commit(); + if (event.key === 'Enter') { + event.preventDefault() + void commit() } - if (event.key === "Escape") onClose(); + 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 699d654..d700aad 100644 --- a/src/components/Lightbox.tsx +++ b/src/components/Lightbox.tsx @@ -1,57 +1,59 @@ -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"; +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) - const lightboxRootRef = useRef(null); - const imageViewportRef = useRef(null); - const imgRef = useRef(null); - const [view, setView] = useState(IDENTITY_VIEW); + const lightboxRootRef = useRef(null) + const imageViewportRef = useRef(null) + const imgRef = useRef(null) + const [view, setView] = useState(IDENTITY_VIEW) - 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 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 region = useRegionSelection({ selectedImage, @@ -61,7 +63,7 @@ export function Lightbox() { view, setView, findSimilarByRegion, - }); + }) const slideshow = useSlideshow({ rootRef: lightboxRootRef, @@ -77,13 +79,13 @@ export function Lightbox() { loadMoreImages, exitRegionMode: region.exitRegionMode, setView, - }); + }) const resetForSelectedImage = useCallback(() => { - setView(IDENTITY_VIEW); - region.exitRegionMode(); - region.setRegionSearching(false); - }, [region.exitRegionMode, region.setRegionSearching]); + setView(IDENTITY_VIEW) + region.exitRegionMode() + region.setRegionSearching(false) + }, [region.exitRegionMode, region.setRegionSearching]) const details = useLightboxMediaDetails({ selectedImage, @@ -92,7 +94,7 @@ export function Lightbox() { getImageExif, loadTaggerModelStatus, onSelectedImageReset: resetForSelectedImage, - }); + }) const { goPrev, goNext } = useLightboxNavigation({ selectedImage, @@ -109,15 +111,15 @@ export function Lightbox() { openImage, setView, clampPan: region.clampPan, - }); + }) const toggleRegionMode = useCallback(() => { if (region.regionSelectMode) { - region.exitRegionMode(); + region.exitRegionMode() } else { - region.setRegionSelectMode(true); + region.setRegionSelectMode(true) } - }, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]); + }, [region.exitRegionMode, region.regionSelectMode, region.setRegionSelectMode]) return ( @@ -126,13 +128,19 @@ export function Lightbox() { ref={lightboxRootRef} key="lightbox" className={`media-dark-surface fixed inset-0 z-50 flex ${ - slideshow.active ? "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={slideshow.active ? slideshow.showControls : region.regionSelectMode ? undefined : closeImage} + onClick={ + slideshow.active + ? slideshow.showControls + : region.regionSelectMode + ? undefined + : closeImage + } > {slideshow.active ? ( ) : 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 5b6be57..c4c0c44 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,58 +1,58 @@ -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"; +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' function ActiveSettingsSection({ section }: { section: SettingsSection }) { switch (section) { - case "workspace": - return ; - case "media": - return ; - case "updates": - return ; - case "storage": - return ; - case "general": + case 'workspace': + return + case 'media': + return + case 'updates': + return + case 'storage': + return + case 'general': default: - return ; + return } } export function SettingsModal() { - const [activeSection, setActiveSection] = useState("general"); + const [activeSection, setActiveSection] = useState('general') - 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); + 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); + if (event.key === 'Escape') setSettingsOpen(false) + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) }, [ settingsOpen, loadTaggerModelStatus, @@ -63,14 +63,18 @@ export function SettingsModal() { loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen, - ]); + ]) - if (!settingsOpen) return null; + if (!settingsOpen) return null - const activeSectionMeta = SETTINGS_SECTIONS.find((section) => section.id === activeSection) ?? SETTINGS_SECTIONS[0]; + const activeSectionMeta = + SETTINGS_SECTIONS.find((section) => section.id === activeSection) ?? SETTINGS_SECTIONS[0] return ( -
setSettingsOpen(false)}> +
setSettingsOpen(false)} + >
event.stopPropagation()} @@ -84,7 +88,9 @@ export function SettingsModal() {
-
+
- ); + ) } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6af9414..b1f26fc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,54 +1,54 @@ -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"; +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 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); + 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 ( -