102 Commits

Author SHA1 Message Date
LyAhn cad3b9c57d chore(release): 0.2.0
github/actions/release GitHub Actions release finished: success
github/actions/ci GitHub Actions CI finished: success
2026-07-11 19:13:58 +01:00
LyAhn e551b15aca ci: support rebuilding a release tag via workflow_dispatch
release.yml gains a required tag input so an existing tag can be rebuilt
without re-pushing it. The tag is checked out via refs/tags/, verified
against package.json and Cargo.toml versions before any toolchain setup,
and Gitea commit statuses are posted against the tag commit rather than
GITHUB_SHA (which is the dispatched branch head on workflow_dispatch).
2026-07-11 19:12:52 +01:00
LyAhn 0b4459365d ci: run unit and Rust tests, tighten triggers and permissions
Expand path triggers to all build inputs (configs, lockfile, index.html,
CHANGELOG.md which feeds the What's New modal), add pnpm test:unit and
cargo test steps, read-only permissions, a 60-minute timeout, and a
workflow-scoped concurrency group.
2026-07-11 19:12:41 +01:00
LyAhn 4aa74d535b fix(ui): fix alignment and sizing of the update pending icon
github/actions/ci GitHub Actions CI finished: success
2026-07-11 12:08:54 +01:00
LyAhn 48df4f2965 style: Format db.rs
github/actions/ci GitHub Actions CI finished: success
2026-07-06 20:53:14 +01:00
LyAhn 2bc4a98164 fix(frontend): derive THEME_OPTIONS from an exhaustive AppTheme record
github/actions/ci GitHub Actions CI finished: failure
The quick theme switcher's option list was a hand-maintained array that
could drift from the AppTheme union. Derive it from a
Record<AppTheme, string> instead, so adding a theme forces this
mapping to be updated too.
2026-07-06 20:42:06 +01:00
LyAhn 6923777345 test(frontend): cover mediaSrc, changelog, and timeline grouping
Unit tests for three previously-untested pure functions: mediaSrc's
UI Lab path handling, getChangelogForVersion's version normalization,
and groupImages' date bucketing/sorting.
2026-07-06 20:41:42 +01:00
LyAhn 96e62cb7c1 perf(frontend): invalidate duplicate-scan cache concurrently and safely
Cache invalidation across affected folders after a bulk delete ran
sequentially through awaited invoke calls, duplicated between
gallerySlice and duplicateSlice. Extract a shared
invalidateDuplicateScanCaches helper that batches the calls with
Promise.allSettled, logging failures instead of throwing — a
best-effort cache refresh should never turn an already-successful
delete into a rejected promise for the caller.
2026-07-06 20:41:16 +01:00
LyAhn 42564a93e0 fix(backend): escape LIKE wildcards and harden curl arg passing
Filename/tag search treated literal % and _ in query text as SQL
wildcards; escape them (with ESCAPE '\') before building LIKE patterns.
Also pass -- before the URL in curl invocations so a URL starting with
a dash can't be misread as an option.
2026-07-06 20:40:37 +01:00
LyAhn b8d009c973 fix(frontend): sync sidebar pause menu with per-worker background task state
github/actions/ci GitHub Actions CI finished: success
Only require a worker to be paused if it currently has pending work for
that folder, instead of demanding all four worker flags be true. The
background tasks panel only lets you toggle stages it shows, so the old
check stayed out of sync after pausing everything visible.

Also grants start-dragging and start-resize-dragging window permissions.
2026-07-06 00:47:13 +01:00
LyAhn dcc1612802 merge: add automated test coverage
github/actions/ci GitHub Actions CI finished: success
Merge the Tauri test feature branch into main.

Adds Vitest coverage for frontend formatting, path, and store helper behavior, plus Rust unit coverage for database, vector, storage, indexer, thumbnail, and AI tag filtering logic. Also wires the new unit test scripts into the package commands and documents the test layers.
2026-07-05 21:26:32 +01:00
LyAhn 2c699a5aac docs(claude): document the new unit-test layers and commands
CLAUDE.md claimed the Playwright smoke tests were the only tests; add
the Vitest and cargo-test commands and describe the three test layers,
including the shared db::test_support fixture for DB-touching Rust
tests.
2026-07-05 21:20:24 +01:00
LyAhn ca5c500e18 test(backend): cover storage, indexer, thumbnail, and tag-filter logic
storage.rs: thumbnail worker clamping across parallelism levels, the
adaptive-profile EMA transitions, and UNC-path fallback detection.
indexer.rs: supported-media extension matching plus media-kind and MIME
mapping. thumbnail.rs: fit_dimensions aspect-ratio math with extreme
ratios and is_jpeg extension checks. ai_tag_filter.rs: padding and
mixed-separator normalization edge cases.
2026-07-05 21:19:50 +01:00
LyAhn 782cf0ea08 test(backend): add in-memory SQLite test harness with db and vector tests
A shared test_support module in db.rs provides the fixture: sqlite-vec
registered via auto-extension, an in-memory connection with foreign keys
on, and both migrations applied - no refactoring of production code
needed since every query function already takes &Connection.

db.rs coverage: folder idempotency, upsert_image update semantics
(favorite/rating preserved, AI tag state invalidated), the get_images
filter matrix with pagination and count_images agreement, tag
merge/rename/delete, user-tag precedence over AI tags in update_ai_tags,
album CRUD with FK cascade, the embedding job queue (backfill, retry,
consistency repair), tag search, and delete_folder cascades.

vector.rs coverage: pack/unpack round-trip, embedding upsert/delete with
dimension validation, and find_similar_image_ids ranking on both the
global KNN and folder-scoped brute-force paths.
2026-07-05 21:19:42 +01:00
LyAhn 5004a2d01a test(frontend): add Vitest unit tests for store helpers and utilities
Wire Vitest into the existing Vite config (scoped to src/**/*.test.ts so it
never collides with the Playwright suite in tests/) and add test:unit,
test:unit:watch, and test:rust scripts.

Covers the pure logic layer: parseSearchValue prefix parsing, all twelve
sort orders through mergeImages, merge/dedup/window helpers, filter
matching, gallery request tokens, localStorage-backed initial settings,
folder-picker path utilities, and the duplicate/lightbox/gallery/video
formatters. Fixture factories live in src/test/factories.ts.
2026-07-05 21:19:31 +01:00
LyAhn 9a282dda86 docs(claude): document UI Lab, Playwright e2e tests, and tooling scripts
CLAUDE.md still claimed no test suites existed. Update it for the
current repo state:

- add pnpm test:e2e commands (full run, single file, single test) and
  note the suite runs against UI Lab mocks, not the Tauri app
- new UI Lab section: src/dev mock layer, ?scenario=/?changelog= params,
  mediaSrc rule, and the mockBackend requirement for new commands
- add dev:ui and format:all to the command list
- note website/ as a separate Vite project and document the
  changelog:add script syntax
2026-07-05 20:48:16 +01:00
LyAhn b23212ea1c refactor(backend): extract download and onnx_runtime modules from captioner
github/actions/ci GitHub Actions CI finished: success
captioner.rs had grown into two things: the (currently disabled)
Florence-2 captioner plus generic infrastructure the live tagger
depends on. Split the neutral parts out:

- download.rs: resilient curl downloader (resume, stall detection)
  and NuGet package extraction
- onnx_runtime.rs: shared ONNX Runtime/DirectML DLL manifest,
  provisioning, and ort init, with runtime_dir() as the single
  definition of the DLL location (kept inside the caption model dir
  so existing installs do not re-download)

Also dedupes the tagger-side copies of the DLL list and four
hardcoded caption-model path literals, and rewords tagger runtime
errors that wrongly told users to download the caption model.
2026-07-05 20:21:47 +01:00
LyAhn a791f112f5 fix(downloads): invoke system curl portably instead of curl.exe
Spawn bare `curl` (which Windows still resolves to curl.exe via
CreateProcess, and macOS/Linux ship natively) and write the size-probe
output to the platform null device instead of the Windows-only NUL,
removing the hard Windows dependency from the model download path.
Spawn quirks (console-window suppression) are centralized in a new
curl_command() helper.
2026-07-05 20:21:23 +01:00
LyAhn 7020a6b6cf style(whats-new): reformat WhatsNewModal via pnpm format
github/actions/ci GitHub Actions CI finished: success
2026-07-05 15:02:27 +01:00
LyAhn bf38fac30d test(e2e): wire Playwright to the UI Lab with scenario smoke tests 2026-07-05 15:02:26 +01:00
LyAhn 79e2e28979 refactor(dev): move DemoPanel into src/dev
DemoPanel is dev-only tooling like the rest of the UI Lab mock code, so it
lives with it now instead of among the real UI components. Import path in
App.tsx updated; the DEV gating (and production tree-shaking) is unchanged.
2026-07-05 13:38:52 +01:00
LyAhn aa3fe2062d feat(ui-lab): add first-run and update scenarios
github/actions/ci GitHub Actions CI finished: success
Add separate UI Lab scenarios for true first-run onboarding and post-update What's New checks. Keep empty-library fixtures reusable across mock data, tagger readiness, worker pause defaults, and launch-time version state so browser verification can exercise the real flows.
2026-07-04 21:41:34 +01:00
LyAhn af1a443a64 feat(ui-lab): preview What's New entries via ?changelog=
Add a UI-Lab-only (mode === "ui", dead-code-eliminated from real builds)
URL override for the What's New modal: ?changelog=unreleased shows the
in-progress notes before they ship, ?changelog=small serves a synthetic
hotfix-sized entry to exercise the compact layout (including a no-lead
bullet), and ?changelog=<version> previews any released entry. Documented
in docs/ui-lab.md alongside the ?scenario= table.
2026-07-04 21:15:17 +01:00
LyAhn 302a3151ef feat(whats-new): adapt modal layout to release size
Small releases keep the compact single-column list; releases with more than
20 bullets across 2+ sections switch to a wider two-pane layout with a
section nav rail, so no pane is a marathon scroll. The threshold lives in
RAIL_THRESHOLD; single-section entries always stay single-column.
2026-07-04 21:14:45 +01:00
LyAhn d7c3cbd683 docs(changelog): fill unreleased gaps and give Fixed notes a pain-first pass
Add the user-facing changes that had no entry: the Settings page reorg, the
AI-tag reset flow, the onboarding tagger-model choice + per-model confidence
thresholds, and the lightbox tagger-readiness fix. Fold two more papercuts
(search-field button alignment, selectable menu labels) into the papercuts
bullet.

Rework several Fixed entries to open with what the user used to suffer
rather than the fix, Discord-patch-notes style.
2026-07-04 21:14:29 +01:00
LyAhn 2d567c0810 Merge refactor/modularize-components: modular components, store slices, shared menus, prettier
github/actions/ci GitHub Actions CI finished: success
- Zustand store split from one god file into per-feature slices under
  src/store/ (library, gallery, search, explore, albums, duplicates,
  tagger, captions, settings, app) with shared helpers and event wiring.
- All large components modularized into per-feature subfolders:
  Lightbox, SettingsModal, Sidebar, ExploreView, FolderPickerModal,
  BackgroundTasks, Toolbar, Gallery, Timeline, DuplicateFinder,
  VideoPlayer, and BulkActionBar.
- Context menus, dropdowns, and dismissal handling consolidated into a
  shared menu system (src/components/menu/); dead MenuBar removed;
  shared icons extracted into icons.tsx.
- New: add-to-album submenu on the image right-click menu.
- Fixes: menu labels no longer text-selectable, duplicate tile React
  keys, mock IPC results cloned to match real invoke semantics, tile
  tooltip and folder picker glitches.
- Tooling: prettier added with format/format:rust scripts, one-shot
  format of the frontend, LF line endings enforced via .gitattributes.
2026-07-04 20:31:47 +01:00
LyAhn 5ff3ff53a6 chore: enforce LF line endings via .gitattributes
Prettier enforces endOfLine: lf, but core.autocrlf=true was converting
working-copy files to CRLF on checkout, which would flip format:check
red after every branch switch. eol=lf keeps LF in the working tree;
binary assets and Windows scripts are exempted.
2026-07-04 20:26:05 +01:00
LyAhn 827e1a8ecf 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.
2026-07-04 20:23:32 +01:00
LyAhn 32c6ae09d6 chore: add prettier with format scripts
Prettier config (no semi, single quotes, 100 cols, tailwindcss plugin)
scoped via .prettierignore to frontend code only; markdown, docs, CI
workflows, website/, and src-tauri/ are excluded. Scripts follow the
toolchain split: format/format:check (prettier), format:rust(:check)
(cargo fmt), format:all for both.
2026-07-04 20:23:19 +01:00
LyAhn 44179c83a7 refactor(bulk-action-bar): modularize component
Break down the monolithic BulkActionBar component into smaller, highly cohesive modules to improve maintainability and separation of concerns.

- Extract distinct UI regions and popovers into dedicated components (`BulkSelectionSummary`, `BulkAlbumPopover`, `BulkDeleteConfirm`, `BulkRatingPopover`, and `BulkTagPopover`).
- Abstract shared panel states and configurations into a dedicated `types.ts` file within the `bulk` directory.
- Simplify the main `BulkActionBar` component to act as a clean presentation orchestrator.
2026-07-04 19:49:22 +01:00
LyAhn 4fcc42f356 refactor(video-player): modularize component
Break down the monolithic VideoPlayer component into smaller, highly cohesive modules to improve maintainability and separate concerns.

- Extract UI sub-components into dedicated files (`VideoControls` and `ControlButton`).
- Abstract complex playback state, event listeners, scrubbing, and keyboard shortcuts into a custom `useVideoPlayer` hook.
- Move time formatting utilities to a dedicated `format.ts` file.
- Relocate shared constants and types to dedicated files (`constants.ts` and `types.ts`).
- Simplify the main `VideoPlayer` component to act as a lightweight presentation orchestrator.
2026-07-04 19:41:46 +01:00
LyAhn aa3d843a4b refactor(duplicate-finder): modularize component
Break down the monolithic DuplicateFinder component into smaller, highly cohesive modules to improve maintainability and separation of concerns.

- Extract empty, loading, and intro states into dedicated components (`DuplicateFinderEmptyStates`).
- Extract the complex header and action logic into `DuplicateFinderHeader`.
- Move `DuplicateGroupCard` to its own dedicated component file.
- Relocate formatting utilities like byte conversion and relative time strings to a dedicated `format.ts` file.
2026-07-04 19:36:07 +01:00
LyAhn 01faec9155 refactor(gallery): modularize gallery and timeline
Break down the monolithic Gallery and Timeline components into smaller, highly cohesive modules to improve maintainability and separation of concerns.

- Extract core UI elements into dedicated components (`ImageTile`, `TruncatedFilename`, `ScrubberYearBlock`).
- Separate loading and empty states into dedicated files (`GalleryEmptyState`, `TimelineEmptyState`).
- Move complex timeline grouping, row virtualization mapping, and duration formatting logic into utility files (`timelineModel.ts` and `format.ts`).
- Abstract shared data structures into `types.ts` to cleanly decouple domain logic from the presentation layer.
2026-07-04 19:30:54 +01:00
LyAhn 3242897a3b refactor(toolbar): modularize component
Break down the monolithic Toolbar component into smaller, highly cohesive modules to improve maintainability and separation of concerns.

- Extract distinct UI regions into dedicated components (`ToolbarTitle`, `ToolbarSearch`, `SortControl`, `ZoomControl`, `ToolbarFilters`, and `FilterPill`).
- Abstract complex search state, debouncing, event listeners, and autocomplete logic into a custom `useToolbarSearch` hook.
- Relocate search value formatting and sort configurations to dedicated utility files (`searchValue.ts` and `sortOptions.ts`).
- Simplify the main `Toolbar` component to act as a clean presentation orchestrator.
2026-07-04 19:23:02 +01:00
LyAhn 8f424773d2 refactor(folder-picker): modularize component
Break down the monolithic FolderPickerModal component into smaller, highly cohesive modules to improve maintainability and separate concerns.

- Extract UI sub-components into dedicated files (`FolderRow`, `StagedFoldersPanel`, and `StatusLine`).
- Move path manipulation, string formatting, and breadcrumb utilities into a dedicated `pathUtils.ts` file.
- Abstract complex local state, API calls, and event listeners into a custom `useFolderPicker` hook.
- Clean up `FolderPickerModal` to act as a lightweight presentation layer.
2026-07-04 19:14:32 +01:00
LyAhn 2149b4cad5 refactor(bg tasks): modularize component
Break down the monolithic BackgroundTasks component into smaller, highly cohesive modules to improve maintainability and readability.

- Extract complex UI components into dedicated files (`BackgroundTaskSummary`, `ExpandedTaskPanel`, `TaskStagePill`, `TaskProgressBar`, `BackgroundTaskActions`, and `FailedWorkerItemRow`).
- Move task construction, duplicate scan formatting, and progress calculation logic into a dedicated `taskModel.ts` utility file.
- Relocate shared interfaces and constants to `types.ts` to cleanly decouple data structures from the presentation layer.
2026-07-04 19:04:06 +01:00
LyAhn 2901425f42 refactor(sidebar): modularize Sidebar
Break down the monolithic Sidebar component into smaller, maintainable modules to improve readability and separation of concerns.

- Extract `NavItem`, `FolderItem`, and `AlbumItem` into dedicated UI components.
- Modularize domain logic by introducing `LibrarySection` and `AlbumSection`.
- Abstract complex drag-and-drop and ordering state into custom `useFolderOrdering` and `useAlbumOrdering` hooks.
- Move shared constants and types to a dedicated `types.ts` file.
2026-07-04 18:57:36 +01:00
LyAhn 1074c875a3 fix(menus): prevent menu label text from being selectable 2026-07-04 18:30:25 +01:00
LyAhn 58ecb03070 refactor(explore): extract components from ExploreView into src/components/explore/
To de-godify \ExploreView.tsx\, the inline components \ClusterCloud\, \ExploreLoadingPanel\, \TagAtlas\, and \TagManageList\, along with shared constants in \layout.ts\, have been extracted into independent files. This improves modularity and maintainability.
2026-07-04 18:09:41 +01:00
LyAhn 2c0b928bf5 refactor(ui): split SettingsModal into feature slices under src/components/settings/
Extracts the various settings sections (General, Media, Updates, Storage, AI Workspace) from the massive SettingsModal.tsx into distinct sub-components. Introduces a shared.tsx file for common settings UI primitives (SettingsGroup, SettingsItem, StatusPill, etc.) to improve code maintainability and separation of concerns.
2026-07-04 16:46:41 +01:00
LyAhn fe312e7678 refactor(lightbox): split Lightbox into smaller hooks and components
Deconstruct the Lightbox.tsx god component into feature-specific hooks (slideshow, region selection, navigation, media details) and separate UI components (viewport, slideshow view, details panel). Extracted shared types and formatting logic into utility files.
2026-07-04 16:33:54 +01:00
LyAhn 52ac2543ec refactor(store): split store.ts into feature slices under src/store/
Break the 3,244-line monolithic store into a Zustand slice-per-feature
layout (types, helpers, librarySlice, gallerySlice, searchSlice,
exploreSlice, albumSlice, duplicateSlice, taggerSlice, captionSlice,
settingsSlice, appSlice, events) combined in index.ts. Components keep
calling useGalleryStore(s => s.field) against the same flat state
object — no component changes required. Completes the de-godify effort
started with the menu/Dropdown/Sidebar extractions and icons.tsx.
2026-07-04 15:53:52 +01:00
LyAhn fbf8a7878c refactor(ui): extract shared icons into icons.tsx
The ten most-duplicated inline SVGs (Check, Close, ChevronDown/Right,
Plus, Photo, Folder, Warning, Star, Play) become shared components --
34+ copies across 15 files replaced. Stroke icons take a per-site
strokeWidth since weights legitimately differ by context; icons that
appear once or use variant path data stay inline.
2026-07-04 12:29:14 +01:00
LyAhn 4d41f3744f fix(duplicates): key duplicate tiles on the outermost mapped element
The key sat on the button nested inside the Tooltip wrapper, so React
warned about missing keys for every duplicate group tile.
2026-07-04 12:21:04 +01:00
LyAhn c27662dd74 refactor(ui): extract AlbumPicker from BulkActionBar
The album popover body (album list + create-new-album form) moves to
its own component. The host supplies onPick, which both an existing
album row and a freshly created album route through -- BulkActionBar
adds the selection and closes the panel. Ready for reuse anywhere else
an album needs picking (e.g. the context menu submenu later).
2026-07-04 12:11:36 +01:00
LyAhn ee2a1b204e refactor(sidebar): extract InlineRename, InlineConfirm, and NavItem
FolderItem and AlbumItem each carried their own copy of the in-place
rename input (state, select-on-open effect, commit/cancel handling) and
the Confirm/Cancel pair for destructive actions. Both are now shared
components: InlineRename mounts fresh per rename (an external name
change mid-rename no longer clobbers typing), InlineConfirm is the
compact red/gray pair. The four copy-pasted nav rows collapse into a
local NavItem component.
2026-07-04 12:11:26 +01:00
LyAhn 6806703363 fix(dev): clone mock IPC results to match real invoke semantics
Mock handlers return references straight into the in-memory db (e.g.
`return db.albums`), so store updates like set({ albums }) kept the same
array identity across loads and Zustand never notified subscribers --
the sidebar album list froze after creating an album from the bulk bar.
Real invoke() deserializes fresh JSON per call, so production was never
affected. structuredClone in the shim restores that fidelity for every
mock command at once.
2026-07-04 12:11:00 +01:00
LyAhn 90fd6f4fed chore(ui): remove dead MenuBar component
MenuBar has had no importers since the Toolbar superseded it -- its
last real touch was the multi-folder picker work. Delete it and update
the CLAUDE.md component list (which also drifted: Timeline/ExploreView
existed but were missing, TagCloud never split out) plus a pointer to
the shared menu primitives.
2026-07-04 07:22:32 +01:00
LyAhn 053a2bd846 refactor(ui): consolidate dropdowns into one shared Dropdown
ThemedDropdown, Toolbar''s local SortDropdown, and FolderScopeDropdown
were three hand-rolled implementations of the same select pattern. They
are replaced by a single generic Dropdown (src/components/menu/) built
on MenuPanel/MenuItem, with solid/ghost/compact trigger variants and
Object.is value comparison so number|null folder scopes work alongside
string unions. Call sites drop their `value as X` casts.

MenuItem gains an `active` state plus stable menu-panel/menu-item class
hooks, and the subtle-light CSS that previously dressed only the folder
scope dropdown (feature-scope-*) now themes every menu surface --
dropdowns, context menus, and submenus alike.
2026-07-04 07:22:22 +01:00
LyAhn 54016df830 refactor(ui): adopt useDismissable in ColorFilter and BulkActionBar
Replaces the hand-rolled outside-pointerdown listeners with the shared
hook. Both popovers now also close on Escape, and listeners only attach
while a panel is actually open.
2026-07-04 07:22:09 +01:00
LyAhn 5d46ee5b94 feat(gallery): add-to-album submenu on the image right-click menu
Right-clicking an image in the Gallery or Timeline now offers an
"Add to Album" submenu listing all albums with their counts — the first
use of the new SubMenu primitive. Filing a single image away no longer
requires starting a multi-select.
2026-07-03 23:58:18 +01:00
LyAhn 83081928f6 refactor(ui): unify context menus into a shared menu system
Five hand-rolled context menu implementations (image tiles, sidebar
folders, sidebar albums, title bar theme switcher, plus duplicated
close-listener effects in Gallery/Timeline) are replaced by shared
primitives in src/components/menu/:

- useDismissable: one outside-pointerdown + Escape dismissal hook
- Menu.tsx: MenuPanel chrome, MenuItem (danger/disabled/checked/hint),
  MenuSeparator, MenuLabel, and SubMenu with viewport edge-flipping
- ContextMenu: portal-rendered wrapper that measures and clamps to the
  viewport, fixing menus rendering off-screen and the latent
  fixed-inside-transform bug under framer-motion Reorder items

The image right-click menu moves to ImageContextMenu.tsx, shared by
Gallery and Timeline.
2026-07-03 23:54:28 +01:00
LyAhn e374ff6b02 fix(ui): fix tile tooltip + folder picker
- Tooltips now shows their correct px size on hovering

- Remove trailing `\` from drive letter in folder picker UI
2026-07-03 23:27:14 +01:00
LyAhn 3ab9357d6f perf(explore): reduce tag cloud refresh pressure
github/actions/ci GitHub Actions CI finished: success
Debounce Explore tag refreshes while AI tagging is active so the tag cloud catches up after worker activity settles instead of continuously re-querying.

Optimize the tag cloud aggregate query by fetching representative thumbnails in one pass and adding a supporting tag/image index.
2026-07-03 22:43:41 +01:00
LyAhn fe65bc6f38 feat(settings): reorganize preferences pages
Split Settings into General, Media, Updates & Setup, Storage, and AI Workspace pages so update/setup and maintenance controls are easier to reach.

Move runtime check results beside the model runtime controls instead of under model location.
2026-07-03 22:43:41 +01:00
LyAhn 68932b55c5 fix(tagger): separate JoyTag confidence threshold
Store confidence thresholds per tagger model so JoyTag no longer inherits WD tuning. Refresh the active threshold when switching models, guard stale threshold saves, and keep UI Lab mocks in sync.

Also tightens the onboarding model selector so the segmented control no longer stretches across the row.
2026-07-03 22:43:41 +01:00
LyAhn f1116c6c26 feat(onboarding): choose AI tagger model
Add WD and JoyTag selection to the Welcome Tour AI step so users can choose the model before downloading it.

Share tagger model metadata with Settings and keep the Settings close button anchored to the modal chrome.
2026-07-03 22:43:41 +01:00
LyAhn b92b850d02 Merge AI tagger readiness fixes
Refresh selected tagger readiness at startup so the lightbox AI tags action reflects installed model state without needing a Settings refresh.

Add UI Lab coverage for uninstalled WD and JoyTag scenarios, and include the toolbar clear-button alignment follow-up.
2026-07-02 20:59:48 +01:00
LyAhn bf04df7484 fix(toolbar): center search field overlay buttons
The clear button and command-prefix chip sat a few pixels high: their
inline-flex Tooltip wrappers created a line box with descender space
below, making the positioned div taller than the button it centers.
Making the wrappers flex containers collapses them to the button height
so the translate centering lands correctly.
2026-07-02 20:47:22 +01:00
LyAhn d29a779c13 test(ui-lab): add tagger readiness scenarios
Add UI Lab scenarios for uninstalled WD and JoyTag tagger states so the lightbox and AI Workspace readiness flows can be exercised directly.

Make the Settings title-bar button accessible by label to support reliable UI Lab automation.
2026-07-02 20:22:06 +01:00
LyAhn b7e82dbf91 fix(ai-tags): refresh tagger readiness for lightbox
Load the selected tagger model and model status during app startup so the lightbox AI tags action does not stay disabled until Settings refreshes the state.

Refresh readiness after tagger downloads complete and replace stale WD-specific unavailable copy with generic AI tagger wording.
2026-07-02 20:13:45 +01:00
LyAhn 749b23723a Merge lightbox slideshow mode
github/actions/ci GitHub Actions CI finished: success
Add fullscreen slideshow playback for image-only lightbox sessions, including sequential or random order, idle controls, and slideshow settings.

Include the gentle motion transition polish and no-repeat random navigation refinements from the feature branch.
2026-07-02 17:52:30 +01:00
LyAhn 68d19d219e feat(lightbox): add gentle slideshow motion
Add a selectable slideshow transition setting with a gentle motion option, keep the default soft fade, and make random slideshow navigation avoid repeats until the current image pool is exhausted.

Split the slideshow crossfade layer from the slow image drift so transitions overlap cleanly without black frames or lingering stale slide layers.
2026-07-02 11:39:19 +01:00
LyAhn 31b46327fd feat(lightbox): add slideshow mode
Adds a fullscreen image-only slideshow from the current lightbox collection, with pause, keyboard navigation, hidden idle controls, and polished image transitions.

Adds slideshow duration and playback order settings, including random order support.
2026-07-02 08:19:13 +01:00
LyAhn 29d9106039 Merge AI tag maintenance tools
github/actions/ci GitHub Actions CI finished: failure
Add an AI tag reset flow across Settings and Explore so AI-generated tags, AI tagging metadata, and stale tagging jobs can be cleared without affecting user tags or other media data.

Also add subtle AI source indicators in the tag manager and an Extreme UI Lab scenario for stress-testing large tag/library counts.
2026-07-01 11:38:51 +01:00
LyAhn a78111c8d4 test(ui-lab): add extreme mock scenario
Add an extreme UI Lab scenario with virtual-scale library, album, cluster, and tag counts while keeping the rendered media fixture set manageable.

Return the full extreme tag set from the mock backend so tag manager layouts can be stress-tested with 10k-100k tag counts.
2026-07-01 10:31:59 +01:00
LyAhn 4cdbc54d18 feat(ai-tags): add reset flow
Add a reset_ai_tags backend command that removes AI-generated tag rows, clears AI tagging metadata, cancels active tagging jobs, and drops queued or failed tagging jobs in the selected scope.

Expose reset actions in AI Workspace and the Explore tag manager, refresh gallery/progress/tag state after reset, and add subtle AI source indicators to tag manager rows.
2026-07-01 10:31:22 +01:00
LyAhn d5b93b2e21 feat(folder-picker): add editable address navigation
github/actions/ci GitHub Actions CI finished: success
Add a File Explorer-style dual-mode path bar to the folder picker so users can navigate with breadcrumbs or switch into an editable address field for pasted paths. Also allow staging the current or typed path directly and show friendlier feedback for missing folders.
2026-07-01 01:00:18 +01:00
LyAhn 257b2b54e7 chore: update changelog 2026-06-30 23:43:17 +01:00
LyAhn d619b01f2e refactor(ui): replace native app tooltips
github/actions/ci GitHub Actions CI finished: success
Wrap app controls with the shared Tooltip component and use cursor-positioned tooltip placement for icon buttons, chips, path labels, and media controls.

Leave native title attributes on the window chrome buttons so Minimize, Maximize, and Close keep platform-style behavior.
2026-06-30 23:25:43 +01:00
LyAhn 1a971899d1 feat(search): filter tag results by colour
Pass the active colour filter through tag searches and apply the existing palette match inside the tag query and count paths.

Update the UI Lab mock backend so colour filtering behaves the same way when testing tag search results.
2026-06-30 23:24:25 +01:00
LyAhn 8fe5daf25d Dev/codex cloud setup (#9)
## Summary
- Adds `scripts/codex-cloud-setup.sh` for Codex Cloud environment setup
- Installs Linux/Tauri native dependencies, Node/pnpm, Rust tooling, JS dependencies, and Rust crates
- Uses CPU-safe Rust checks with `--no-default-features` because Phokus enables CUDA by default
- Adds UI Lab/browser guidance for `pnpm dev:ui` on port `1422`

## Notes
- The script is intended to be pasted into the Codex Cloud setup field or run from the repo root.
- It avoids full release builds during setup to keep Codex cache warm without making environment creation too heavy.

Reviewed-on: #9
2026-06-30 14:35:20 +00:00
LyAhn 619bd0c9d2 Merge: rename 'tag cloud' to 'visual clusters' across the stack
github/actions/ci GitHub Actions CI finished: success
Disambiguate the Explore feature naming: the visual k-means cluster view was
named tag_cloud/tagCloud/TagCloud everywhere while the actual tag list is
explore_tags, which was easy to confuse. The cluster side is now
visual_cluster/visualCluster/VisualCluster (command, types, store, DB cache
table, and the ExploreView component); the tags side and the user-facing
"Tag Cloud" label are unchanged. Includes a mock-fixture fix so the dev
"huge" scenario surfaces a realistic field of clusters.
2026-06-30 10:01:25 +01:00
LyAhn 996bb71375 fix(mock): scale visual-cluster fixtures to the scenario
The mock Explore clusters were a fixed 10 regardless of scenario, so the "huge"
dev scenario looked sparse next to its large tag vocabulary. Mirror the backend's
k = (n / 20).clamp(5, 30): the huge scenario now surfaces ~30 clusters with a
long-tailed, big-library-sized count distribution, so Explore looks realistic.
2026-06-30 09:59:50 +01:00
LyAhn 0d9229635b refactor(explore): rename misnamed "tag cloud" to "visual clusters"
The visual k-means cluster feature was confusingly named tag_cloud / tagCloud /
TagCloud across the whole stack, while the actual tag list is explore_tags — the
two were trivially easy to mix up (and did cause confusion). Rename the cluster
side to visual_cluster / visualCluster / VisualCluster everywhere: command
get_tag_cloud -> get_visual_clusters (+ lib.rs registration and the invoke
string), VisualClusterEntry, the store fields/actions/tokens, and the mock
backend. Old names are retired rather than reused, so any missed reference fails
loudly instead of silently resolving to the wrong concept.

The tags side keeps its accurate explore_tags naming, and the user-facing
"Tag Cloud" UI label is unchanged.

Also rename the SQLite tag_cloud_cache table -> visual_cluster_cache (the old
table is dropped during schema setup — it is a disposable cache already
invalidated by the clustering version bump) and the TagCloud.tsx component file
-> ExploreView.tsx, since it is the Explore container hosting both the cluster
and tag views.
2026-06-30 09:48:38 +01:00
LyAhn cdb8aa20b9 Merge: faster Explore visual clustering on large libraries
Sampled, parallel k-means with density-aware k-means++ seeding makes first-time
visual clustering on large libraries fast, without a single cluster swallowing
tens of thousands of generic images. Together with the earlier cache-hit
optimization, the Explore tab no longer stalls for several seconds on 80k+
libraries.
2026-06-30 09:10:15 +01:00
LyAhn d2af84d9e8 perf(explore): sampled, parallel visual clustering for large libraries
Computing visual clusters was O(n·k·dim) per Lloyd iteration over the whole
library, single-threaded — several seconds on an 80k-image library on first
view. Find centroids on a deterministic, evenly-strided sample (<=3000
embeddings) and then assign every image to its nearest centroid in one parallel
rayon pass. Libraries at or below the sample cap are unchanged.

Replace the greedy farthest-point seeding (which seeds outliers, leaving the
dense core under-represented on a sample so one centroid absorbed tens of
thousands of generic images) with proper density-aware k-means++ D² seeding,
made deterministic via a small fixed-seed SplitMix64 PRNG. This keeps clusters
balanced on large libraries.

A CLUSTER_CACHE_VERSION is folded into the tag-cloud cache key so existing
caches computed by the old algorithm are invalidated and recomputed. The
clustering timing line and the cache-write failure now go through the `log`
facade (debug/warn) instead of eprintln.
2026-06-30 09:08:48 +01:00
LyAhn 68a9df5ab3 feat(lightbox): two-column metadata layout
github/actions/ci GitHub Actions CI finished: failure
Lay the lightbox info-panel metadata out in a two-column grid: paired fields
(Dimensions/Duration, Video codec/Audio codec, Type/File size) sit side by side
while Rating, Modified, and Embedding span the full width. More compact panel
with less scrolling. Tags and EXIF sections are unchanged.
2026-06-29 20:35:13 +01:00
LyAhn 79ce458fd5 feat(tags): open the tag manager from Settings
Add an "Open tag manager" button under a new Tag library group in Settings →
AI Workspace. It closes Settings and jumps to Explore's tag Manage mode.

To make manage mode reachable from outside the Explore view, lift its flag out
of TagCloud's local state into the store (tagManagerOpen / setTagManagerOpen)
behind an openTagManager() action. Manage mode is reset whenever Explore is
entered normally or the visual-cluster view is selected, so openTagManager()
stays the only path that opens it programmatically.
2026-06-29 20:26:49 +01:00
LyAhn a9a8f8422e feat(ui): quick theme switch from the settings cog
Right-clicking the settings cog in the title bar opens a small theme menu
(Phokus / Subtle Light / Conventional Dark) with the active theme checked,
anchored under the cog and dismissed on outside-click or Escape. Left-click
still opens Settings. Keeps theme switching one gesture away without cluttering
the title bar with another icon.
2026-06-29 20:26:08 +01:00
LyAhn ab7022e118 perf(explore): instant tag-cloud cache hits + fix stale-on-switch loading
The get_tag_cloud cache key was built by loading and hashing every embedding
blob for the scope *before* checking the cache, so even a cache hit re-read
hundreds of MB on large libraries and stalled Explore for several seconds.
Validate the cache from a lightweight image-ID-set signature plus the embedding
revision instead, so a hit never loads embeddings. The ID-set hash keeps the key
membership-sensitive (add/remove/move between folders) and the revision covers
an image being re-embedded in place. Cache write failures are now logged rather
than silently ignored.

On the frontend, switching folders (or re-entering Explore) no longer leaves the
previous folder's clusters/tags on screen with no loading indicator:
loadTagCloud/loadExploreTags clear stale entries on a real folder switch. The
displayed folder is tracked separately (exploreTagsShownFolderId) from the
cache-dirty marker so a same-folder invalidation (tag edits, new AI tags) does
not masquerade as a switch and wipe the visible list mid-refresh.
2026-06-29 20:25:38 +01:00
LyAhn 23e9850c7a Merge feat/tagging-ux: tagging model choice, related tags, tag manager, and UX polish
github/actions/ci GitHub Actions CI finished: failure
- JoyTag added as a second selectable tagger alongside WD; model switches on demand, tags attributed per-model

- Related tags atlas in Explore: clicking a tag shows co-occurring tags with animated connection lines and image counts

- Persist worker pauses across restarts via a Settings toggle

- Tag manager gains live filter, sort (most-used / least-used / A–Z / Z–A), and virtualisation for large libraries

- Tooltip portal with anchorToCursor mode for precise hover positioning

- Noisy AI tags (e.g. generic background descriptors) filtered at store time; existing tags cleaned on migration

- Fix: tag cloud hover glow and atlas gradient now adapt to Subtle Light theme

- Fix: AI Workspace 'Selected Folders' scope no longer pre-selects the first folder

- Fix: changelog version lookup strips build suffixes so What's New works in UI Lab
2026-06-29 18:36:01 +01:00
LyAhn c111032d99 fix(changelog): strip build suffixes before version lookup
UI-lab builds append a suffix to the version string (e.g.
"0.1.1-ui") which caused getChangelogForVersion to find no match and
fall back to the "not available in-app" message. A regex now strips any
hyphen-and-letter suffix before the lookup so the What's New modal
renders correctly in all build modes.
2026-06-29 18:06:45 +01:00
LyAhn c13f78c68b docs(changelog): add unreleased entries for feat/tagging-ux
Adds the user-facing changes introduced on this branch that were missing
from the [Unreleased] section:

Added:
- Related tags in Explore (tag atlas connection lines + co-occurrence counts)
- Persist worker pauses across restarts (Settings toggle)

Changed:
- Tag manager search/sort/virtualisation

Fixed:
- Noisy AI tags filtered automatically (removal list applied at store time)
- Explore Tag Cloud hover glow and atlas gradient in Subtle Light theme
- AI Workspace "Selected Folders" scope no longer pre-selects a folder
2026-06-29 17:23:20 +01:00
LyAhn f2939d70ab fix: atlas glow and AI Workspace scope pre-selection in light theme
Two targeted fixes:

- Tag Cloud atlas SVG radial gradient now switches its inner stop from white
  to a warm dark tone (rgba 60 50 30) when the Subtle Light theme is active,
  matching the explore-tag hover glow style used elsewhere in light mode.
- Switching the AI Workspace tagging queue scope to "Selected Folders" no
  longer auto-selects the first folder in the list; the selection now starts
  empty so the user can choose exactly which folders to target.
2026-06-29 17:23:03 +01:00
LyAhn 8dbabc2d9e fix: match tag cloud hover glow to dark mode in light theme
Replace the flat background hover override with a dark warm radial
gradient on the ::before pseudo-element, mirroring the same elliptical
glow effect used in dark mode.
2026-06-29 17:06:50 +01:00
LyAhn 5bc397af01 chore: add Playwright test scaffolding
Adds @playwright/test and @types/node as dev dependencies, playwright.config.ts
with a localhost:1420 base URL targeting the Vite dev server, an example
spec, and the standard Playwright output directories to .gitignore.
2026-06-29 16:30:05 +01:00
LyAhn af3c8418ee feat: virtualized tag manager with filter, sort, and light-theme support
Replaces the flat TagManageRow list with a virtualized grid of
TagManageTile cards using @tanstack/react-virtual and dynamic measured
heights (46px idle, 82px when editing/confirming). Adds a filter input
and a sort dropdown (most-used / least-used / A-Z / Z-A). renameTag and
deleteTag no longer clear exploreTagEntries on invalidation so the
manager keeps its filter/sort state during the background refresh.
Light-theme overrides cover all new tag-manager class names.
2026-06-29 16:29:12 +01:00
LyAhn 9144be2518 feat: Tooltip portal with anchorToCursor mode
Tooltip now portals cursor-anchored variants into document.body via a
`mounted` guard, preventing transformed parents and scroll-container
overflow from distorting coordinates. New `anchorToCursor` prop locks
position at hover entry without tracking; `followCursor` retains the
spring-animated tracking behaviour. Color swatches (ColorFilter),
timeline scrubber dots/labels (Timeline), and toolbar dropdowns (Toolbar)
are updated to use the appropriate cursor mode. Toolbar z-index bumped
z-20→z-40 (dropdowns z-30→z-50) to layer above portaled content; tag
autocomplete result guard added (Array.isArray).
2026-06-29 16:28:35 +01:00
LyAhn d81624573d feat: persist worker-pause state across restarts
Worker pause states can optionally survive app restarts. A new toggle in
Settings saves the current pause map to settings/worker_pauses.json; on
startup lib.rs restores it before workers are spawned. Backend: new
snapshot/replace helpers in indexer.rs, persist functions in commands.rs
(get/set_worker_pauses_persist). Frontend: workerPausesPersist store
field, load/setWorkerPausesPersist actions, toggle in SettingsModal.
2026-06-29 13:21:53 +01:00
LyAhn 949382f28c feat: related-tags atlas in Explore view
Hovering a tag in Explore now loads and displays co-occurring tags as a
weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs),
`loadRelatedTags` store action with per-folder keyed cache, and TagCloud
atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag
limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging
completes.
2026-06-29 13:21:43 +01:00
LyAhn e4a63c8bb0 fix: filter noisy AI tags
Add an editable AI tag removal list and apply it to WD/JoyTag output before tags are stored. Clean existing generated AI tags during database migration while leaving manual user tags untouched.
2026-06-29 11:24:21 +01:00
LyAhn 1685134116 fix(tagger): model-neutral session log; changelog for JoyTag
create_tagger_session is shared by both models but logged "WD tagger: using
CPU execution provider" even when loading JoyTag. Make the CPU/DirectML
session logs model-neutral. Add the JoyTag tagging-model picker to the
changelog now that it's runtime-verified.
2026-06-29 10:26:51 +01:00
LyAhn 705f8c2e56 feat(tagger): Settings picker to switch tagging model (WD / JoyTag)
Adds a "Tagging model" control to Settings → AI Workspace that calls
get/set_tagger_model, alongside the existing acceleration/threshold/batch
controls. Switching refreshes the model status so the download/ready row and
the model name/description reflect the selected model, and the threshold hint
shows the model-appropriate default (0.4 for JoyTag, 0.35 for WD).

Frontend only; mirrors the existing acceleration toggle pattern. End-to-end
JoyTag tagging still needs the model files on disk to verify.
2026-06-29 09:51:30 +01:00
LyAhn 52d54d2404 feat(tagger): add JoyTag provider behind a tagger-model abstraction
Introduces a selectable tagging model so Phokus isn't locked to the
anime-leaning WD tagger. JoyTag uses the Danbooru schema but generalizes to
photographic content and is strong on NSFW concepts — a better fit for a photo
manager while keeping the explicitness range.

- `TaggerModel` enum (wd | joytag) persisted as a `tagger_model` setting;
  `model_dir`, status, and download now follow the active model (per-model
  repo/dir/file list). WD stays the default.
- `Tagger` trait implemented by `WdTagger` and the new `JoyTagger`;
  `create_active_tagger` builds the selected one. The worker holds
  `Box<dyn Tagger>` and rebuilds on model change (TAGGER_SESSION_DIRTY).
- Shared `assemble_batch` skeleton (pack -> one forward pass -> per-image
  fallback, results in input order); both providers and the shared
  decode/pad/resize are de-duped onto common helpers.
- JoyTag specifics: NCHW + RGB + CLIP-normalized input (vs WD's NHWC/BGR/raw),
  flat top_tags.txt labels, logits -> sigmoid -> threshold (default 0.4). It has
  no native rating, so the explicitness rating is derived from its NSFW tags.
- Tags are attributed to the model that produced them (ai_tagger_model),
  via Tagger::model_name, instead of a hardcoded WD constant.
- New get/set_tagger_model commands.

Backend only; the Settings model picker and end-to-end testing against the
JoyTag model files come next.
2026-06-29 09:40:12 +01:00
LyAhn 3a2b134103 Merge perf/tagging: faster, non-freezing AI tagging
github/actions/ci GitHub Actions CI finished: success
Tagging inference no longer monopolises the GPU. It now runs in small
chunked forward passes with a brief yield between them, so a tagging run
keeps the UI responsive instead of freezing the whole app for seconds at a
time — worst on first launch with a cold batch. Throughput is also steadier
(the old wide batches caused periodic slowdowns).

CPU-provider tagging is multi-threaded instead of pinned to a single core
(~2.7x faster on a representative machine), leaving headroom so the rest of
the app stays responsive. DirectML/GPU tagging behaviour is unchanged.
2026-06-29 09:19:40 +01:00
LyAhn 71ad7bf762 perf(tagger): multi-thread CPU inference instead of pinning to one core
The ONNX session was built with intra_threads(1) unconditionally. That's
correct for DirectML (compute is on the GPU), but on the CPU execution
provider it pinned all matmul/conv work to a single core — ~1.78s/image,
~14s for an 8-image batch.

Derive the intra-op thread count from available_parallelism() for the CPU EP
(leaving 2 logical cores free for the UI and a possible concurrent scan;
tagging is the lowest-priority worker, so heavier workers are idle when it
runs). DirectML/Auto keep a single thread. Measured ~2.7x on a representative
machine (14s -> 5.3s per 8-image batch); sublinear because swinv2 inference is
memory-bandwidth-bound. The selected thread count is logged at load.
2026-06-29 09:09:29 +01:00
LyAhn 992417710f perf(tagger): chunked, yielding inference so tagging stops freezing the UI
The tagging worker claimed a batch from the DB but ran the model one image
at a time, so `tagger_batch_size` had no effect on inference. Batching the
whole claim into a single DirectML forward pass fixed that but introduced a
worse problem: on a shared GPU each wide dispatch locks the device (and the
WebView2 compositor with it) for 1-3.7s, freezing the entire app while
tagging runs — worst of all on first launch with a cold graph compile.

The WD model is compute-bound here (~50-230ms/image of actual GPU work), so
a wide batch buys almost no throughput; it only lengthens each uninterruptible
GPU lock. So decouple DB claim size from GPU granularity: claim
`tagger_batch_size` for DB efficiency, but feed the GPU in TAGGER_INFER_CHUNK
(4) images per forward pass with a brief yield between chunks. Each dispatch
now lasts ~200-900ms, the UI gets windows to paint, peak decode memory is
bounded, and the cold compile is for a smaller shape. As a bonus the wide-batch
throughput spikes disappear — steady ~1.6-1.8s/16 vs the old 0.8-3.7s swings.

- run_batch: parallel (rayon) decode + single forward pass per chunk, with
  per-image fallback and decode failures kept attached to their input slot.
- Worker iterates source_paths.chunks(TAGGER_INFER_CHUNK), yielding 40ms
  between chunks; outputs zip back to jobs 1:1, write tx unchanged.
- Remove now-dead WdTagger::run() (fallback uses infer_one directly).
2026-06-29 01:09:47 +01:00
LyAhn 4c6da99507 Merge feat/ui-lab: Add browser-only UI Lab
github/actions/ci GitHub Actions CI finished: success
- Adds a Vite UI Lab mode that boots the real Phokus frontend with mocked
  Tauri APIs.
- Introduces deterministic mock backend scenarios for rich, empty, busy,
  duplicate, album, error, and large-library states.
- Adds fixture media plus a mediaSrc helper so browser previews and Tauri file
  URLs share one rendering path.
- Documents the UI Lab workflow and exposes it through pnpm dev:ui.
2026-06-29 00:12:51 +01:00
LyAhn 24d4e82950 feat: expand UI Lab media fixtures
Add a derived dev-media fixture pack and wire UI Lab scenarios to use the broader image set for more useful browser screenshots.
2026-06-29 00:06:58 +01:00
LyAhn 7a18011b0f feat: add Phokus UI Lab
Add a dev-only Vite UI mode with Tauri API mocks, in-memory fixture scenarios, reusable media source handling, and documentation for browser-based visual testing.
2026-06-29 00:04:48 +01:00
LyAhn cebd709391 fix: anchor cluster Open pill to corner so it stays on small cards
github/actions/ci GitHub Actions CI finished: success
2026-06-28 21:19:32 +01:00
244 changed files with 22434 additions and 11799 deletions
+22
View File
@@ -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
+34 -1
View File
@@ -4,16 +4,39 @@ on:
push: push:
branches: [main] branches: [main]
paths: paths:
- '.github/workflows/ci.yml'
# CHANGELOG.md is a build input: src/changelog.ts imports it ?raw for
# the What's New modal, and changelog.test.ts asserts on its content.
- 'CHANGELOG.md'
- 'index.html'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'src/**' - 'src/**'
- 'src-tauri/**' - 'src-tauri/**'
- 'tsconfig*.json'
- 'vite.config.ts'
pull_request: pull_request:
paths: paths:
- '.github/workflows/ci.yml'
# CHANGELOG.md is a build input: src/changelog.ts imports it ?raw for
# the What's New modal, and changelog.test.ts asserts on its content.
- 'CHANGELOG.md'
- 'index.html'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'src/**' - 'src/**'
- 'src-tauri/**' - 'src-tauri/**'
- 'tsconfig*.json'
- 'vite.config.ts'
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
concurrency: concurrency:
group: ci-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
@@ -21,7 +44,9 @@ jobs:
# windows-latest to match the release target — the ML crates (ort, candle) # windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows. # and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 60
env: env:
CARGO_INCREMENTAL: 0
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }} GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps: steps:
- name: Report pending status to Gitea - name: Report pending status to Gitea
@@ -58,10 +83,14 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install frontend dependencies - name: Install frontend dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: Unit tests
run: pnpm test:unit
# tsc + vite build; also produces dist/ which tauri's build script expects # tsc + vite build; also produces dist/ which tauri's build script expects
- name: Type-check and build frontend - name: Type-check and build frontend
run: pnpm build:vite run: pnpm build:vite
@@ -85,6 +114,10 @@ jobs:
working-directory: src-tauri working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- name: Rust tests
working-directory: src-tauri
run: cargo test --locked --no-default-features
- name: Report final status to Gitea - name: Report final status to Gitea
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != '' if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true continue-on-error: true
+50 -5
View File
@@ -8,15 +8,59 @@ on:
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to build, for example v0.1.1'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
cancel-in-progress: false
jobs: jobs:
release: release:
permissions: permissions:
contents: write contents: write
runs-on: windows-latest runs-on: windows-latest
timeout-minutes: 120
env: env:
CARGO_INCREMENTAL: 0
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }} GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
steps: steps:
# refs/tags/ qualification: a branch with a tag-like name must never win
# ref resolution. Works for tag pushes too — RELEASE_TAG is the tag name.
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ env.RELEASE_TAG }}
# On workflow_dispatch GITHUB_SHA is the dispatched branch head, not the
# tag's commit — resolve the real one for Gitea commit statuses.
- name: Resolve release commit
shell: pwsh
run: |
$sha = git rev-parse HEAD
"RELEASE_SHA=$sha" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Verify release version
shell: pwsh
run: |
if ($env:RELEASE_TAG -notmatch '^v\d+\.\d+\.\d+(-.+)?$') {
throw "Release tag '$env:RELEASE_TAG' must look like v0.1.1"
}
$packageVersion = (Get-Content package.json -Raw | ConvertFrom-Json).version
$cargoVersion = (Select-String -Path src-tauri/Cargo.toml -Pattern '^version\s*=\s*"(.+)"').Matches[0].Groups[1].Value
if ($packageVersion -ne $cargoVersion) {
throw "package.json version ($packageVersion) does not match Cargo.toml version ($cargoVersion)"
}
if ($env:RELEASE_TAG -ne "v$packageVersion") {
throw "Release tag '$env:RELEASE_TAG' does not match project version v$packageVersion"
}
- name: Report pending status to Gitea - name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != '' if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true continue-on-error: true
@@ -36,13 +80,11 @@ jobs:
} | ConvertTo-Json } | ConvertTo-Json
Invoke-RestMethod ` Invoke-RestMethod `
-Method Post ` -Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" ` -Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:RELEASE_SHA" `
-Headers $headers ` -Headers $headers `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
version: 11 version: 11
@@ -51,6 +93,7 @@ jobs:
with: with:
node-version: 22 node-version: 22
cache: pnpm cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -71,7 +114,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with: with:
tagName: ${{ github.ref_name }} tagName: ${{ env.RELEASE_TAG }}
releaseName: 'Phokus v__VERSION__' releaseName: 'Phokus v__VERSION__'
releaseBody: 'See the assets below to download and install this version.' releaseBody: 'See the assets below to download and install this version.'
releaseDraft: true releaseDraft: true
@@ -105,9 +148,11 @@ jobs:
description = "GitHub Actions release finished: $env:JOB_STATUS" description = "GitHub Actions release finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json } | ConvertTo-Json
# RELEASE_SHA is unset if the job died before checkout; fall back.
$sha = if ($env:RELEASE_SHA) { $env:RELEASE_SHA } else { $env:GITHUB_SHA }
Invoke-RestMethod ` Invoke-RestMethod `
-Method Post ` -Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" ` -Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$sha" `
-Headers $headers ` -Headers $headers `
-ContentType "application/json" ` -ContentType "application/json" `
-Body $body -Body $body
+8 -1
View File
@@ -35,5 +35,12 @@ dist-ssr
*.pyc *.pyc
# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit # Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit
# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant". # locally; ~600 MB, never committed).
src-tauri/cuda-redist/ src-tauri/cuda-redist/
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+30
View File
@@ -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/
+14
View File
@@ -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"]
}
+133 -78
View File
@@ -5,94 +5,148 @@ All notable changes to Phokus are documented here. The format is based on
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions). (0.x: anything may change between minor versions).
## [Unreleased] ## [0.2.0] — 2026-07-11
### Added ### Added
- **Albums** — curate your own collections. A new Albums section in the sidebar
(with cover thumbnails, kept visually distinct from Libraries) lets you create,
rename, reorder (drag the row), and open albums; albums can span multiple
folders. Add images from the gallery's bulk action bar or from the lightbox,
remove them from within an album, and use Manage mode to multi-select and
delete albums in one go. Deleting an album never touches your files — only the
grouping is removed.
- **Multi-select & bulk actions in the gallery** — hover a thumbnail's top-left
corner to reveal a selection checkbox (click it to start selecting); while
selecting, click tiles to toggle and double-click to open. A floating action
bar then lets you tag (with autocomplete), rate, favorite, add to an album, or
delete the whole selection at once. Works in similar-image, region, and album
views too.
- **Colour search** — filter the gallery (and Timeline) by dominant colour via a
collapsible swatch palette plus a custom colour picker in the toolbar; existing
libraries are backfilled automatically in the background.
- **Album-scoped similar search** — when finding visually similar images or
searching by a selected region from an album, you can now keep results scoped
to that album, switch back to the source folder, or search all media.
- **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag
list where you can rename a tag, merge it into another (rename it to an
existing tag's name), or delete it from every image. Changes apply across the
whole library.
- **Camera info in the lightbox** — the image info panel now shows EXIF details
(camera, lens, aperture, shutter, ISO, focal length) and, when a photo is
geotagged, its GPS coordinates as a link that opens the location in your
browser. Read on demand from the file, so it works on already-indexed images
without re-indexing.
- **What's New** — after updating, Phokus now greets you with a "What's new" - **What's New** — after updating, Phokus now greets you with a "What's new"
toast that opens an in-app release-notes screen for the new version, with the toast that opens a tidy in-app tour of the new version. Added, Changed, and
changes grouped into collapsible Added / Changed / Fixed sections. Fixed notes are grouped into collapsible sections, so you can skim the good
- **Build badge in Settings** — the version line in Settings → Updates now shows bits without playing "spot the difference".
whether the running build is the CPU or CUDA (GPU-accelerated) variant. - **Quick theme switch** — right-click the settings cog in the title bar to
swap between Phokus, Subtle Light, and Conventional Dark instantly. No Settings
detour required.
- **Albums** — make your own cross-folder collections without moving a single
file. Albums live in their own sidebar section with cover thumbnails, can be
created, renamed, reordered, opened, and cleaned up in Manage mode, and deleting
one only removes the grouping.
- **Gallery multi-select** — hover a thumbnail's top-left corner to start
selecting, then use the floating action bar to tag, rate, favorite, add to an
album, or delete a whole batch at once. It also works in similar-image, region,
and album views, because bulk work should not disappear the moment you need it.
- **Colour search** — narrow the Gallery, Timeline, or tag results by dominant
colour using toolbar swatches or a custom picker. Great for those "I know it
was mostly blue" moments.
- **Album-aware similar search** — similar-image and region searches started from
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.
- **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
do not need a re-index.
- **Build badge in Settings** — Settings -> Updates now shows whether you are
running the CPU build or the CUDA build.
- **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).
New users get the same choice during the welcome tour, and each model keeps its
own confidence threshold instead of sharing one.
- **Reset AI tags** — a new reset action in Settings -> AI Workspace and the Tag
manager wipes AI-generated tags for a folder or the whole library, cancelling
any tagging still in flight. Tag manager rows now show which tags came from the
AI, so you know what you are about to lose before you lose it. Manually-added
tags are never touched.
- **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.
- **Pause workers for longer** — Settings -> General can now remember per-folder
worker pauses across app restarts, useful for folders you want to keep in the
library but leave out of background processing for now.
- **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.
- **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 ### Changed
- **Safer deletion** — deleting media now asks for confirmation and spells out - **Settings got a spring clean** — preferences are now organised into General,
that it permanently removes the file(s) from disk. This covers the new gallery Media, Updates & Setup, Storage, and AI Workspace pages, so update and
bulk delete and the Duplicate Finder, which previously deleted on a single maintenance controls no longer hide at the bottom of unrelated sections.
click with no confirmation or warning. - **Menus got their act together** — right-click menus (images, folders,
- The updater now shows a real download progress bar with a percentage in albums, the theme switcher) and every dropdown (sort, folder scope, settings,
Settings → Updates (previously it only said "Downloading"). sidebar) now share one style with one set of manners: they stay on screen
- **Smaller-screen layout** — the toolbar adapts on narrow windows: the filter instead of wandering off the edge, all close on Escape, and right-click menus
chips scroll horizontally instead of squashing (so "Similar: Folder/All" no can do proper submenus now. Subtle Light dresses them all the same way too,
longer stack), the search box shrinks, and the colour-search swatches now open instead of saving the nice outfit for one dropdown.
in a compact popover rather than expanding inline and running off-screen. The - **Neater lightbox details** — image and video metadata now sits in two columns,
sidebar and the lightbox info panel also narrow to give the gallery more room. so the info panel shows more at a glance with less scrolling.
- **Explore cluster layout** — clusters are now sized by image count (busier - **Faster Explore revisits** — returning to a folder's visual clusters should
clusters are larger and stack on top) and repositioned to avoid overlapping, so feel much faster now, even in big libraries.
every cluster stays viewable and clickable even in dense libraries. - **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the
tag list while a folder is actively being tagged, so tagging stays smoother and
the cloud catches up once the work settles.
- **Faster first-time clustering** — large libraries build their first visual
clusters much more quickly, while still keeping the groups nicely balanced.
- **Better tag browsing** — the Tag manager now has live search, sorting
(most-used, least-used, A-Z, and Z-A), smooth scrolling for huge tag lists, and
it keeps your filter/sort in place while you edit.
- **Safer deletion** — deleting media now asks for confirmation and clearly says
the file is being removed from disk. This covers gallery bulk delete and the
Duplicate Finder.
- **Clearer update progress** — Settings -> Updates now shows a real download
progress bar with a percentage instead of the old lonely "Downloading" label.
- **Better narrow-window layout** — the toolbar, filters, search box, colour
picker, sidebar, and lightbox info panel now adapt more gracefully when the
window is short on space.
- **Tidier Explore clusters** — busier clusters get more room, dense groups
overlap less, and everything should stay easier to read and click.
- **Faster CPU tagging** — CPU-only AI tagging can now use multiple cores while
leaving some breathing room for the rest of the app. GPU tagging is unchanged.
- **Smoother tooltips** — Phokus now uses its custom tooltip style across more of
the app instead of falling back to the native browser tooltip.
### Fixed ### Fixed
- **Rating no longer scrambles search results** — rating or favoriting an image - **Explore no longer flashes the last folder** — switching folders now clears
while viewing similar-image, region, semantic, tag, or album results no longer the old clusters/tags and shows a loading state while the new folder catches
re-sorts the view back into the default order; the current result ordering is up.
preserved. - **Ratings keep your search order** — if you ever rated an image mid-search and
- The update download/install progress toast now reappears when you start an watched your results reshuffle themselves, that's over. Similar-image, region,
update from the title-bar indicator or Settings after dismissing the earlier semantic, tag, and album results now stay put.
"Update available" prompt — previously progress only showed in Settings. - **Update progress comes back when you need it** — if you dismiss the update
- Subtle Light theme — fixed several surfaces and buttons that stayed dark or prompt and later start the update from the title bar or Settings, the progress
became unreadable on hover, including new dialogs and the green action buttons toast now reappears instead of hiding away in Settings.
across the updater and onboarding. - **Subtle Light cleanup** — fixed dark or hard-to-read surfaces, hover states,
- **Endless self-indexing loop** — adding a folder that contains Phokus's own dialogs, updater buttons, onboarding controls, and green action buttons in the
app-data directory (for example, your whole user profile) no longer makes the light theme.
app index its own thumbnail cache, generate thumbnails of those thumbnails, and - **No more self-indexing loops** — adding a broad folder like your whole user
loop forever. That directory is now skipped by both the folder scanner and the profile used to send Phokus off indexing its own thumbnail cache, generating
filesystem watcher. thumbnails of thumbnails until the end of time. It now skips its own app-data
- **Background tasks now lead with the active folder** — when one folder's directory.
workers are paused while another is actively processing, the active folder - **Background tasks show the active work first** — when one folder is paused and
takes the main slot in the background-tasks bar instead of the paused one. another is processing, the active folder gets the main spot in the background
- **Oversized window on smaller screens** — on a fresh install the window opened tasks bar.
at a fixed size that was taller than the usable area on displays like - **First launch fits smaller screens** — on 1366x768-style displays, fresh
1366×768, leaving part of it off-screen. It now clamps to the monitor's work installs used to open with part of the app tucked below the taskbar. The window
area (excluding the taskbar) and centres there when it would overflow; larger now clamps itself to the usable monitor area.
displays and any remembered size/position are left untouched. - **Explore is clearer in Subtle Light** — cluster captions, buttons, cloud
- **Explore readability in Subtle Light** — fixed washed-out and unreadable words, hover glows, and the new connection lines now use stronger light-theme
elements in the Explore view on the light theme. Cluster cards now use a soft colours.
dark caption scrim (the previous cream overlay fogged the photos), with a - **Explore got a few sharp edges sanded down** — Cluster Cloud uses the in-app
legible label, count, and "Open" button; Tag Cloud words use darker accents and tooltip, singular counts now say "1 image", and the folder-scope dropdown no
a higher opacity floor instead of near-invisible pale text. longer hides behind cluster cards.
- **Explore polish** — the Tag Cloud now uses the in-app tooltip instead of the - **AI tagging stays responsive** — starting a big tagging job used to turn the
native browser one (and reads "1 image", not "1 images"), and the folder-scope rest of the app into a slideshow. GPU tagging now works in smaller bursts with
dropdown no longer opens behind the cluster cards. brief pauses between them, so the UI keeps moving and the first results land
sooner.
- **Lightbox AI tags wake up on time** — the lightbox's AI tags action no longer
stays disabled until you happen to open Settings, and it switches on as soon as
a model download finishes.
- **Noisy AI tags get cleaned up** — generic low-signal tags from WD and JoyTag
are filtered before they are saved, and matching older generated tags are
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 handful of tiny UI papercuts are gone** — the zoom buttons now show the
right tile size when you hover them, the folder picker no longer adds an odd
trailing slash to Windows drive breadcrumbs, the search field's clear button
no longer sits a few pixels too high, and menu labels no longer highlight like
text when you drag across them.
## [0.1.1] — 2026-06-23 ## [0.1.1] — 2026-06-23
@@ -210,5 +264,6 @@ installer with a built-in updater.
Settings, with live size/reclaimable stats. Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling. - **Window state** persistence and single-instance handling.
[0.2.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.2.0
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1 [0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0 [0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
+41 -5
View File
@@ -15,6 +15,9 @@ pnpm dev:app
# Frontend only (no Tauri window) # Frontend only (no Tauri window)
pnpm dev:vite pnpm dev:vite
# UI Lab — browser-only frontend with mocked Tauri backend (http://127.0.0.1:1422)
pnpm dev:ui
# Production build (CPU) # Production build (CPU)
pnpm build:app:cpu pnpm build:app:cpu
@@ -23,27 +26,56 @@ pnpm build:app:cuda
# Type-check frontend # Type-check frontend
pnpm build:vite pnpm build:vite
# Frontend unit tests (Vitest; only picks up src/**/*.test.ts)
pnpm test:unit
pnpm test:unit:watch
# Rust unit tests (in-memory SQLite; --no-default-features skips CUDA)
pnpm test:rust
# E2E tests (Playwright against the UI Lab; auto-starts the server)
pnpm test:e2e
pnpm exec playwright test tests/ui-lab.spec.ts # single file
pnpm exec playwright test -g "filename search" # single test by name
# Formatting (Prettier + prettier-plugin-tailwindcss; cargo fmt for Rust)
pnpm format:all
``` ```
Use **pnpm** — never npm. Use **pnpm** — never npm.
There are no test suites configured. Three test layers, none of which exercise the real Tauri window:
- **Vitest unit tests** — co-located `*.test.ts` next to the pure logic they cover (`src/store/helpers.ts`, formatters, path utils); fixture factories in `src/test/factories.ts`.
- **Rust unit tests** — inline `#[cfg(test)]` modules; DB tests run against in-memory SQLite via the shared `db::test_support` fixture (`test_conn()` + `test_image()`), which registers sqlite-vec and applies both migrations. Reuse it for any new DB-touching tests.
- **Playwright e2e smoke tests** in `tests/` — run against the UI Lab (browser mocks).
## Architecture ## Architecture
### Frontend (`src/`) ### 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). - **`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. - State management: Zustand v5, no selectors library — components call `useGalleryStore(s => s.field)` directly.
- Styling: Tailwind CSS v4 (Vite plugin, no config file). - Styling: Tailwind CSS v4 (Vite plugin, no config file).
- Virtualized gallery grid: `@tanstack/react-virtual`. - Virtualized gallery grid: `@tanstack/react-virtual`.
- Animation: `framer-motion`. - Animation: `framer-motion`.
### UI Lab (`src/dev/`)
`pnpm dev:ui` runs the real frontend (same `App.tsx`, store, components, CSS) in a plain browser with Tauri fully mocked — no Rust backend or Tauri window. `src/main.tsx` imports `src/dev/setupMockTauri.ts` before `App` in `ui` mode; `mockBackend.ts` implements an in-memory command backend, with fixtures in `mockFixtures.ts`/`mockScenarios.ts`. Pick a seeded state via `?scenario=` (`rich` default; also `empty`, `new-user`, `just-updated`, `busy`, `duplicates`, `album`, `errors`, `huge`) and a What's New entry via `?changelog=`. `Ctrl+Shift+D` opens the demo panel. Full guide: `docs/ui-lab.md`.
Rules that keep UI Lab working:
- Components that render thumbnails/covers/posters must use `mediaSrc(...)` from `src/lib/mediaSrc.ts`, never `convertFileSrc(...)` directly.
- New Tauri commands invoked from the frontend need a mock in `src/dev/mockBackend.ts` (unmocked commands log console errors, which fail the e2e tests).
UI Lab is for visual/layout work and agent browser inspection. Native behavior (file pickers, real thumbnails, window controls, updater) must still be validated in `pnpm dev:app`.
### Search modes ### 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) - No prefix / `f:` — filename search (paginated, DB-backed)
- `/s <query>` or `s: <query>` — semantic (embedding) search - `/s <query>` or `s: <query>` — semantic (embedding) search
- `/t <tag>` or `t: <tag>` — tag search - `/t <tag>` or `t: <tag>` — tag search
@@ -67,6 +99,8 @@ Key modules:
| `hnsw_index.rs` | In-memory HNSW index wrapper (hnsw_rs) | | `hnsw_index.rs` | In-memory HNSW index wrapper (hnsw_rs) |
| `tagger.rs` | WD tagger: ONNX model download, inference, CSV tag loading | | `tagger.rs` | WD tagger: ONNX model download, inference, CSV tag loading |
| `captioner.rs` | AI captioning (ONNX, disabled in workers but code intact) | | `captioner.rs` | AI captioning (ONNX, disabled in workers but code intact) |
| `download.rs` | Resilient file downloads via the system `curl` (resume, stall detection) |
| `onnx_runtime.rs` | Shared ONNX Runtime DLL provisioning + `ort` init (used by tagger and captioner; DLLs live in the caption model dir for legacy reasons) |
| `thumbnail.rs` | Thumbnail generation (image crate + fast_image_resize, FFmpeg for video) | | `thumbnail.rs` | Thumbnail generation (image crate + fast_image_resize, FFmpeg for video) |
| `media.rs` | FFmpeg sidecar provisioning and probing | | `media.rs` | FFmpeg sidecar provisioning and probing |
| `storage.rs` | `StorageProfile` for tuning worker counts | | `storage.rs` | `StorageProfile` for tuning worker counts |
@@ -87,7 +121,7 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
### Key types ### 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 ## Development notes
@@ -95,3 +129,5 @@ Database: SQLite with WAL mode, stored in the Tauri app data directory as `galle
- ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable. - ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable.
- The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling. - The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling.
- **Never use `any` type** in TypeScript — look up correct types. - **Never use `any` type** in TypeScript — look up correct types.
- `website/` is a separate Vite project for the marketing site (phokus.jezz.wtf): `pnpm dev:web` / `pnpm build:web`. It is not part of the app build.
- `pnpm changelog:add -- --type fixed --message "..."` appends an entry to the `[Unreleased]` section of `CHANGELOG.md`, which also feeds the in-app What's New modal (types: added, changed, deprecated, removed, fixed, security).
+6
View File
@@ -109,6 +109,9 @@ pnpm dev:app
# Frontend only # Frontend only
pnpm dev:vite pnpm dev:vite
# Browser-only UI Lab with mocked Tauri APIs
pnpm dev:ui
# Production build (CPU) # Production build (CPU)
pnpm build:app:cpu pnpm build:app:cpu
@@ -119,6 +122,9 @@ pnpm build:app:cuda
pnpm build:vite pnpm build:vite
``` ```
For visual frontend work without launching Tauri or the Rust backend, see
[Phokus UI Lab](docs/ui-lab.md).
## How it works ## How it works
1. Add a folder from the sidebar — the Rust indexer walks it recursively. 1. Add a folder from the sidebar — the Rust indexer walks it recursively.
+85
View File
@@ -0,0 +1,85 @@
# Phokus v0.2.0
> Draft for the GitHub Release body. Fill in the checksums and trim as needed.
**Phokus is a local-first desktop media library for Windows** — point it at
your image and video folders and it builds a fast, searchable gallery with
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
cleanup. Everything is processed on your machine; nothing is uploaded.
0.2.0 is the biggest update since launch: albums, gallery multi-select with
bulk actions, colour search, a second AI tagging model (JoyTag), a full tag
manager, camera EXIF details in the lightbox, a slideshow mode, and a large
batch of performance and theme fixes. Updating from 0.1.x? The built-in
updater will fetch this for you — and afterwards, a new in-app "What's New"
tour shows you around.
## Install / update
- **Updating from 0.1.x:** the in-app updater will offer 0.2.0 on launch — one
click downloads, installs, and relaunches.
- **Fresh install:** download `Phokus_0.2.0_x64-setup.exe` below and run it.
**Windows SmartScreen will warn** that the publisher is unrecognized — this
build is **not code-signed**. Click **More info → Run anyway**.
- Requires **Windows 10/11**. WebView2 is fetched automatically if missing.
NVIDIA users wanting GPU embedding/tagging speed can use the
`Phokus_0.2.0_x64-cuda-setup.exe` variant instead (larger download; bundles the
CUDA runtime DLLs). Settings → Updates now shows which build you are running.
## Highlights
### Added
- **Albums** — cross-folder collections without moving files: their own
sidebar section with cover thumbnails, create/rename/reorder/manage, and
album-aware similar-image search.
- **Gallery multi-select** — hover a thumbnail's corner to start selecting,
then bulk tag, rate, favorite, add to an album, or delete from a floating
action bar. Works in similar-image, region, and album views too.
- **Colour search** — filter the Gallery, Timeline, or tag results by dominant
colour via toolbar swatches or a custom picker.
- **Choose your tagging model** — pick between the anime-focused WD tagger and
**JoyTag** (better for photo libraries), each with its own confidence
threshold, plus a **Reset AI tags** action that never touches manual tags.
- **Tag manager** — rename, merge, and delete tags across the library, with
live search and sorting; Explore's Tag Cloud also shows **related tags** on
hover.
- **Camera info in the lightbox** — EXIF camera, lens, aperture, shutter
speed, ISO, focal length, and a map link for geotagged photos.
- **Slideshow mode**, **What's New tour after updates**, **quick theme switch**
from the title bar, an editable path bar in the folder picker, persistent
per-folder worker pauses, and add-to-album from the right-click menu.
### Changed
- **Settings reorganised** into General, Media, Updates & Setup, Storage, and
AI Workspace pages.
- **Menus unified** — all context menus and dropdowns share one style, stay on
screen, close on Escape, and support submenus.
- **Faster Explore** — quicker cluster revisits, much faster first-time
clustering on large libraries, and a calmer Tag Cloud during active tagging.
- **Faster CPU tagging** (multi-core with headroom) and smoother GPU tagging.
- **Safer deletion** — deleting media now asks for confirmation and says
clearly that files are removed from disk.
- Better narrow-window layouts, a real update download progress bar, and a
two-column lightbox info panel.
### Fixed
- **No more self-indexing loops** — Phokus now skips its own thumbnail cache
when you add a broad folder like your user profile.
- **Ratings keep your search order** — rating an image mid-search no longer
reshuffles similar-image, region, semantic, tag, or album results.
- **AI tagging stays responsive** — big GPU tagging jobs no longer freeze the
UI, and noisy low-signal tags are filtered (older ones cleaned up on
startup).
- First launch now fits smaller screens, Explore no longer flashes the
previous folder, and a long list of Subtle Light readability fixes.
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Checksums
```
SHA-256 (Phokus_0.2.0_x64-setup.exe) = TBD
SHA-256 (Phokus_0.2.0_x64-cuda-setup.exe) = TBD
```
+201
View File
@@ -0,0 +1,201 @@
# Phokus UI Lab
Phokus UI Lab is a browser-only development mode for visual work on the real
Phokus frontend. It runs the same `App.tsx`, Zustand store, components, and CSS
as the Tauri app, but installs Tauri JavaScript mocks before the app imports.
This gives UI agents and contributors a stable browser target without launching
the Rust backend or a Tauri window.
## Run It
```bash
pnpm dev:ui
```
Then open:
```text
http://127.0.0.1:1422
```
The script runs Vite in the custom `ui` mode:
```bash
vite --mode ui --host 127.0.0.1 --port 1422 --strictPort
```
The normal app development commands are unchanged:
```bash
pnpm dev:app # Tauri app + Rust backend
pnpm dev:vite # frontend dev server for Tauri dev
```
## Scenarios
UI Lab reads `?scenario=` from the URL. If no scenario is provided, it uses
`rich`.
| URL | Purpose |
| --- | --- |
| `/?scenario=rich` | Default realistic library with folders, albums, ratings, favorites, tags, images, and videos |
| `/?scenario=empty` | Empty library with no folders or media (onboarding already completed) |
| `/?scenario=new-user` | True first run: onboarding tour open, empty library, no tagger model downloaded |
| `/?scenario=just-updated` | Rich library that has just been updated — the "What's new" toast fires on launch |
| `/?scenario=busy` | Background workers with pending thumbnail, metadata, embedding, caption, and tagging jobs |
| `/?scenario=duplicates` | Duplicate Finder opened with duplicate groups already available |
| `/?scenario=album` | Gallery opened directly into an album |
| `/?scenario=errors` | Broken thumbnails, folder scan errors, failed embeddings, failed tagging, and metadata issues |
| `/?scenario=huge` | Large mock library for virtualization and layout checks |
Examples:
```text
http://127.0.0.1:1422/?scenario=duplicates
http://127.0.0.1:1422/?scenario=errors
http://127.0.0.1:1422/?scenario=huge
```
## Changelog Previews
The What's New modal adapts its layout to release size (compact single column
for small releases, a two-pane section rail for large ones). UI Lab reads
`?changelog=` to override which entry the modal shows:
| URL | Purpose |
| --- | --- |
| `/?changelog=unreleased` | The in-progress `[Unreleased]` notes — large release, rail layout |
| `/?changelog=small` | Synthetic hotfix-sized entry — compact single-column layout |
| `/?changelog=0.1.1` | Any specific released version |
Open the modal via the demo panel: `Ctrl+Shift+D` → Open "What's new" modal.
Combine with the scenario above to walk the whole post-update greeting for the
next release: `/?scenario=just-updated&changelog=unreleased`.
## How It Works
`src/main.tsx` bootstraps the app asynchronously. In `ui` mode it imports
`src/dev/setupMockTauri.ts` first, then imports the real `App`.
That order matters because static imports are hoisted. The Tauri mocks must be
installed before `App`, the store, the title bar, or visual components import
Tauri APIs.
The mock setup installs:
- `mockWindows("main")` for window APIs used by the title bar
- `mockConvertFileSrc("windows")` as a baseline Tauri file URL mock
- `mockIPC(..., { shouldMockEvents: true })` for `invoke`, `listen`, and `emit`
The in-memory backend lives in:
```text
src/dev/mockBackend.ts
src/dev/mockFixtures.ts
src/dev/mockScenarios.ts
src/dev/applyMockScenario.ts
```
Happy-path fixture media is generated from existing onboarding images and
website screenshots into:
```text
public/dev-media/fixture-*.webp
```
The pack is deliberately small and deterministic, but varied enough for browser
screenshots: square, portrait, landscape, monochrome, tinted, framed, and
interface-like crops. Synthetic or deliberately broken fixture paths can still
use the `mock://` scheme described below.
## Mock Media
Visual components should use `mediaSrc(...)` instead of calling
`convertFileSrc(...)` directly:
```ts
import { mediaSrc } from "../lib/mediaSrc";
const src = mediaSrc(image.thumbnail_path);
```
In normal Tauri modes, `mediaSrc` delegates to `convertFileSrc`. In UI Lab,
root-relative asset URLs such as `/dev-media/fixture-01.webp` are returned
as-is. Paths beginning with `mock://` are also served from `public/dev-media`:
```text
mock://thumb-1.svg -> /dev-media/thumb-1.svg
```
Use `mediaSrc` when adding components that render thumbnails, album covers,
preview images, or video posters.
## Adding A Mock Command
If the browser console logs an unmocked command, add it to
`src/dev/mockBackend.ts`.
Prefer returning realistic data for commands that affect visible UI. For actions
that would touch the machine, return a safe no-op:
```ts
case "open_app_data_folder":
return null;
```
When adding fixture data, keep it shaped like the real store/Rust contracts.
Most frontend-facing types are exported from `src/store.ts`, so the mocks can
stay type-checked against the real UI.
## Adding A Scenario
1. Add the scenario name to `MockScenario` and `SCENARIOS` in
`src/dev/mockScenarios.ts`.
2. Adjust fixture creation in `src/dev/mockFixtures.ts`.
3. If the scenario should open a specific view, add that behavior in
`src/dev/applyMockScenario.ts`.
4. Visit `http://127.0.0.1:1422/?scenario=your-scenario` and check for console
errors.
## What UI Lab Is For
UI Lab is intended for:
- visual iteration on the real app shell
- layout and responsive checks
- state-heavy views like Explore, Timeline, Duplicates, albums, and Settings
- AI-agent screenshot/browser inspection
- safe UI work without filesystem or Rust-side effects
It is not a replacement for Tauri app testing. Validate native behavior with
`pnpm dev:app` when touching:
- file or folder pickers
- filesystem permissions
- real thumbnail/video loading
- window drag, minimize, maximize, or close behavior
- Rust backend performance
- updater installation
- WebView2-specific behavior
## Verification
Useful checks after changing UI Lab:
```bash
pnpm exec tsc --noEmit
pnpm build:vite
pnpm dev:ui
```
Then smoke-test at least:
```text
http://127.0.0.1:1422/?scenario=rich
http://127.0.0.1:1422/?scenario=empty
http://127.0.0.1:1422/?scenario=duplicates
http://127.0.0.1:1422/?scenario=errors
http://127.0.0.1:1422/?scenario=huge
```
+18 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "phokus", "name": "phokus",
"private": true, "private": true,
"version": "0.1.1", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -13,12 +13,20 @@
"clean:app": "cd src-tauri && cargo clean", "clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev", "dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features", "dev:app:cpu": "tauri dev -- --no-default-features",
"dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort",
"dev:vite": "vite", "dev:vite": "vite",
"dev:web": "cd website && pnpm dev", "dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt", "format": "prettier --write .",
"format:check": "cd src-tauri && cargo fmt --check", "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", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri",
"test:e2e": "playwright test",
"test:rust": "cd src-tauri && cargo test --no-default-features",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
@@ -36,14 +44,19 @@
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/d3-force": "^3.0.10", "@types/d3-force": "^3.0.10",
"@types/node": "^26.0.1",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"prettier": "^3.9.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4",
"vitest": "^4.1.9"
} }
} }
+75
View File
@@ -0,0 +1,75 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 4,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: 'http://127.0.0.1:1422',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run the Phokus UI Lab (browser-only dev mode, see docs/ui-lab.md) before starting the tests */
webServer: {
command: 'pnpm exec vite --mode ui --host 127.0.0.1 --port 1422 --strictPort',
url: 'http://127.0.0.1:1422',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+382 -14
View File
@@ -48,15 +48,21 @@ importers:
specifier: ^5.0.12 specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4) version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.10.1 version: 2.10.1
'@types/d3-force': '@types/d3-force':
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
'@types/node':
specifier: ^26.0.1
version: 26.0.1
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -65,7 +71,13 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.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: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@@ -74,7 +86,10 @@ importers:
version: 5.8.3 version: 5.8.3
vite: vite:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
website: website:
dependencies: dependencies:
@@ -96,7 +111,7 @@ importers:
devDependencies: devDependencies:
'@tailwindcss/vite': '@tailwindcss/vite':
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -105,7 +120,7 @@ importers:
version: 19.2.3(@types/react@19.2.14) version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.6.0 specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
tailwindcss: tailwindcss:
specifier: ^4.2.2 specifier: ^4.2.2
version: 4.2.2 version: 4.2.2
@@ -114,10 +129,10 @@ importers:
version: 5.8.3 version: 5.8.3
vite: vite:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
vite-imagetools: vite-imagetools:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) version: 10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
packages: packages:
@@ -538,6 +553,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@playwright/test@1.61.1':
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -688,6 +708,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tailwindcss/node@4.2.2': '@tailwindcss/node@4.2.2':
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==} resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
@@ -900,12 +923,21 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-force@3.0.10': '@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/react-dom@19.2.3': '@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies: peerDependencies:
@@ -920,6 +952,39 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@4.1.9':
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
'@vitest/mocker@4.1.9':
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.9':
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
'@vitest/runner@4.1.9':
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
'@vitest/snapshot@4.1.9':
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
'@vitest/spy@4.1.9':
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
'@vitest/utils@4.1.9':
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
baseline-browser-mapping@2.10.14: baseline-browser-mapping@2.10.14:
resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==} resolution: {integrity: sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
@@ -933,6 +998,10 @@ packages:
caniuse-lite@1.0.30001785: caniuse-lite@1.0.30001785:
resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==} resolution: {integrity: sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -975,6 +1044,9 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
es-module-lexer@2.3.0:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
esbuild@0.27.7: esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -987,6 +1059,13 @@ packages:
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1010,6 +1089,11 @@ packages:
react-dom: react-dom:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1140,6 +1224,13 @@ packages:
node-releases@2.0.37: node-releases@2.0.37:
resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1147,10 +1238,80 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'} engines: {node: '>=12'}
playwright-core@1.61.1:
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.61.1:
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.8: postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} 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: react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies: peerDependencies:
@@ -1185,10 +1346,19 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
tailwindcss@4.2.2: tailwindcss@4.2.2:
resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==} resolution: {integrity: sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==}
@@ -1196,10 +1366,21 @@ packages:
resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==}
engines: {node: '>=6'} engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.4:
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
engines: {node: '>=18'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1208,6 +1389,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3: update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true hasBin: true
@@ -1260,6 +1444,52 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@4.1.9:
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.9
'@vitest/browser-preview': 4.1.9
'@vitest/browser-webdriverio': 4.1.9
'@vitest/coverage-istanbul': 4.1.9
'@vitest/coverage-v8': 4.1.9
'@vitest/ui': 4.1.9
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
yallist@3.1.1: yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
@@ -1597,6 +1827,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.4.0(rollup@4.60.1)': '@rollup/pluginutils@5.4.0(rollup@4.60.1)':
@@ -1682,6 +1916,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.1': '@rollup/rollup-win32-x64-msvc@4.60.1':
optional: true optional: true
'@standard-schema/spec@1.1.0': {}
'@tailwindcss/node@4.2.2': '@tailwindcss/node@4.2.2':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@@ -1743,12 +1979,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2
'@tailwindcss/oxide-win32-x64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2
'@tailwindcss/vite@4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies: dependencies:
'@tailwindcss/node': 4.2.2 '@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 4.2.2 '@tailwindcss/oxide': 4.2.2
tailwindcss: 4.2.2 tailwindcss: 4.2.2
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
@@ -1852,10 +2088,21 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-force@3.0.10': {} '@types/d3-force@3.0.10': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/node@26.0.1':
dependencies:
undici-types: 8.3.0
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
dependencies: dependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@@ -1864,7 +2111,7 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -1872,10 +2119,53 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27 '@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.17.0 react-refresh: 0.17.0
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@4.1.9':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
'@vitest/pretty-format@4.1.9':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.9':
dependencies:
'@vitest/utils': 4.1.9
pathe: 2.0.3
'@vitest/snapshot@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
'@vitest/utils': 4.1.9
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.9': {}
'@vitest/utils@4.1.9':
dependencies:
'@vitest/pretty-format': 4.1.9
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
assertion-error@2.0.1: {}
baseline-browser-mapping@2.10.14: {} baseline-browser-mapping@2.10.14: {}
browserslist@4.28.2: browserslist@4.28.2:
@@ -1888,6 +2178,8 @@ snapshots:
caniuse-lite@1.0.30001785: {} caniuse-lite@1.0.30001785: {}
chai@6.2.2: {}
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@@ -1917,6 +2209,8 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.2 tapable: 2.3.2
es-module-lexer@2.3.0: {}
esbuild@0.27.7: esbuild@0.27.7:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7 '@esbuild/aix-ppc64': 0.27.7
@@ -1950,6 +2244,12 @@ snapshots:
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
expect-type@1.4.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1963,6 +2263,9 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -2049,16 +2352,34 @@ snapshots:
node-releases@2.0.37: {} node-releases@2.0.37: {}
obug@2.1.3: {}
pathe@2.0.3: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.4: {} picomatch@4.0.4: {}
playwright-core@1.61.1: {}
playwright@1.61.1:
dependencies:
playwright-core: 1.61.1
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.8: postcss@8.5.8:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.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): react-dom@19.2.4(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4
@@ -2136,37 +2457,51 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5 '@img/sharp-win32-x64': 0.34.5
siginfo@2.0.0: {}
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
tailwindcss@4.2.2: {} tailwindcss@4.2.2: {}
tapable@2.3.2: {} tapable@2.3.2: {}
tinybench@2.9.0: {}
tinyexec@1.2.4: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
tinyrainbow@3.1.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
typescript@5.8.3: {} typescript@5.8.3: {}
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2): update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies: dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1) '@rollup/pluginutils': 5.4.0(rollup@4.60.1)
imagetools-core: 9.1.0 imagetools-core: 9.1.0
sharp: 0.34.5 sharp: 0.34.5
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies: transitivePeerDependencies:
- rollup - rollup
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.27.7 esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@@ -2175,10 +2510,43 @@ snapshots:
rollup: 4.60.1 rollup: 4.60.1
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 26.0.1
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
lightningcss: 1.32.0 lightningcss: 1.32.0
vitest@4.1.9(@types/node@26.0.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
'@vitest/spy': 4.1.9
'@vitest/utils': 4.1.9
es-module-lexer: 2.3.0
expect-type: 1.4.0
magic-string: 0.30.21
obug: 2.1.3
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.0.1
transitivePeerDependencies:
- msw
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
yallist@3.1.1: {} yallist@3.1.1: {}
zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): zustand@5.0.12(@types/react@19.2.14)(react@19.2.4):
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
set -Eeuo pipefail
# Codex Cloud setup for Phokus.
# Paste this script into the Codex Cloud environment setup field, or run it from
# the repo root with: bash scripts/codex-cloud-setup.sh
#
# Goals:
# - install Linux packages needed by Tauri/WebKit and native Rust crates
# - install JS dependencies with pnpm using the lockfile
# - pre-fetch Rust dependencies while setup still has internet access
# - keep the environment CPU-safe by avoiding Phokus' default CUDA feature set
# - leave Codex with clear verification commands for UI and Rust work
log() {
printf '\n\033[1;36m[phokus-codex]\033[0m %s\n' "$*"
}
warn() {
printf '\n\033[1;33m[phokus-codex warning]\033[0m %s\n' "$*" >&2
}
repo_root="$(pwd)"
if [[ ! -f "package.json" || ! -d "src-tauri" ]]; then
warn "This script should be run from the Phokus repository root. Current directory: ${repo_root}"
exit 1
fi
export CI=1
export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
export PATH="$PNPM_HOME:$HOME/.cargo/bin:$PATH"
# Persist useful shell defaults for the later Codex agent phase. Codex setup runs
# in a separate Bash session, so exports here alone would not survive.
if ! grep -q "# Phokus Codex Cloud" "$HOME/.bashrc" 2>/dev/null; then
cat >> "$HOME/.bashrc" <<'BASHRC'
# Phokus Codex Cloud
export PNPM_HOME="${PNPM_HOME:-$HOME/.local/share/pnpm}"
export PATH="$PNPM_HOME:$HOME/.cargo/bin:$PATH"
export CI=1
BASHRC
fi
install_apt_packages() {
if ! command -v apt-get >/dev/null 2>&1; then
warn "apt-get not found; skipping system package installation."
return 0
fi
log "Installing Linux system dependencies for Tauri, WebKit, SQLite/native crates, and browser tooling"
sudo apt-get update
# Tauri v2 Linux builds need the WebKitGTK/AppIndicator/Rsvg stack. Some base
# images expose either the 4.1 or 4.0 WebKit development package, so try the
# modern package first and gracefully fall back.
local common_packages=(
build-essential
curl
wget
file
pkg-config
libssl-dev
libgtk-3-dev
libayatana-appindicator3-dev
librsvg2-dev
patchelf
ca-certificates
)
if apt-cache show libwebkit2gtk-4.1-dev >/dev/null 2>&1; then
sudo apt-get install -y --no-install-recommends "${common_packages[@]}" libwebkit2gtk-4.1-dev
else
sudo apt-get install -y --no-install-recommends "${common_packages[@]}" libwebkit2gtk-4.0-dev
fi
}
ensure_node_and_pnpm() {
log "Preparing Node/pnpm"
if ! command -v node >/dev/null 2>&1; then
warn "Node.js is not available in this image. Pin Node.js 20+ in the Codex environment settings or use a Codex universal image with Node installed."
exit 1
fi
node_major="$(node -p "process.versions.node.split('.')[0]")"
if [[ "$node_major" -lt 20 ]]; then
warn "Phokus expects Node.js 20+. Current version: $(node --version). Pin Node.js 20+ in Codex environment settings."
exit 1
fi
mkdir -p "$PNPM_HOME"
if command -v corepack >/dev/null 2>&1; then
corepack enable
corepack prepare pnpm@latest --activate
fi
if ! command -v pnpm >/dev/null 2>&1; then
npm install -g pnpm
fi
log "Node: $(node --version)"
log "pnpm: $(pnpm --version)"
}
ensure_rust() {
log "Preparing Rust toolchain"
if ! command -v rustup >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
# shellcheck source=/dev/null
source "$HOME/.cargo/env"
fi
rustup toolchain install stable --profile minimal
rustup default stable
rustup component add rustfmt clippy
log "rustc: $(rustc --version)"
log "cargo: $(cargo --version)"
}
install_js_dependencies() {
log "Installing frontend dependencies"
pnpm install --frozen-lockfile
}
prefetch_rust_dependencies() {
log "Pre-fetching Rust dependencies for CPU-safe Tauri checks"
# Phokus enables candle-cuda by default in Cargo.toml. Codex Cloud usually runs
# in a CPU Linux container, so use --no-default-features for checks/builds
# unless you intentionally configure a CUDA-capable environment.
cargo fetch --manifest-path src-tauri/Cargo.toml --locked
# This is intentionally a check, not a full release build. It warms the Cargo
# cache and catches missing native packages without making setup painfully slow.
cargo check --manifest-path src-tauri/Cargo.toml --locked --no-default-features
}
install_playwright_browsers() {
log "Installing Playwright Chromium dependencies for UI Lab/browser screenshots"
# Safe even if no Playwright tests are present yet. Useful for Codex browser
# inspection against `pnpm dev:ui`.
pnpm exec playwright install --with-deps chromium || warn "Playwright browser install failed; UI work may still run, but browser automation/screenshots may need manual setup."
}
print_next_steps() {
cat <<'EOF'
[phokus-codex] Setup complete.
Recommended Codex verification commands:
pnpm exec tsc --noEmit
pnpm build:vite
cargo check --manifest-path src-tauri/Cargo.toml --locked --no-default-features
For visual UI work in Codex/browser environments:
pnpm dev:ui
open http://127.0.0.1:1422/?scenario=rich
Other useful UI Lab scenarios:
http://127.0.0.1:1422/?scenario=empty
http://127.0.0.1:1422/?scenario=duplicates
http://127.0.0.1:1422/?scenario=errors
http://127.0.0.1:1422/?scenario=huge
Avoid the below in standard CPU-only Codex Cloud unless you have configured CUDA:
pnpm dev:app
pnpm build:app:cuda
cargo check --manifest-path src-tauri/Cargo.toml
EOF
}
main() {
install_apt_packages
ensure_node_and_pnpm
ensure_rust
install_js_dependencies
prefetch_rust_dependencies
install_playwright_browsers
print_next_steps
}
main "$@"
+1 -1
View File
@@ -4595,7 +4595,7 @@ dependencies = [
[[package]] [[package]]
name = "phokus" name = "phokus"
version = "0.1.1" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"candle-core", "candle-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "phokus" name = "phokus"
version = "0.1.1" version = "0.2.0"
description = "Local-first desktop media library" description = "Local-first desktop media library"
authors = ["JezzWTF"] authors = ["JezzWTF"]
license = "MIT" license = "MIT"
+7 -3
View File
@@ -2,7 +2,9 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": [
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"opener:default", "opener:default",
@@ -19,6 +21,8 @@
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-is-maximized" "core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging"
] ]
} }
+54
View File
@@ -0,0 +1,54 @@
/// AI-generated tags that are too broad/noisy to be useful in this gallery.
/// Edit this list to change what the tagger removes. Manual user tags are not
/// affected.
pub const AI_TAG_REMOVAL_LIST: &[&str] = &["1girl", "1boy", "no humans", "2girls", "2boys"];
fn normalize_tag_for_removal(tag: &str) -> String {
tag.trim()
.chars()
.filter(|c| !matches!(c, ' ' | '_' | '-'))
.flat_map(char::to_lowercase)
.collect()
}
pub fn is_removed_ai_tag(tag: &str) -> bool {
let normalized = normalize_tag_for_removal(tag);
AI_TAG_REMOVAL_LIST
.iter()
.any(|blocked| normalize_tag_for_removal(blocked) == normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removed_ai_tags_match_common_spellings() {
for tag in [
"1girl",
"1 girl",
"1_girl",
"1-girl",
"NO_HUMANS",
"no humans",
] {
assert!(is_removed_ai_tag(tag), "{tag} should be removed");
}
}
#[test]
fn removed_ai_tags_do_not_match_unrelated_tags() {
for tag in ["girl", "boy", "humans", "solo", "landscape"] {
assert!(!is_removed_ai_tag(tag), "{tag} should be kept");
}
}
#[test]
fn removed_ai_tags_tolerate_padding_and_mixed_separators() {
assert!(is_removed_ai_tag(" 1girl "));
assert!(is_removed_ai_tag("1_-_girl"));
assert!(is_removed_ai_tag("No Humans"));
assert!(!is_removed_ai_tag(""));
assert!(!is_removed_ai_tag(" "));
}
}
+8 -326
View File
@@ -1,3 +1,6 @@
use crate::onnx_runtime::{
self, DIRECTML_DLL_FILE, ONNX_RUNTIME_DLL_FILE, ONNX_RUNTIME_PROVIDERS_DLL_FILE,
};
use anyhow::Result; use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType}; use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, ImageReader}; use image::{imageops::FilterType, ImageReader};
@@ -8,50 +11,16 @@ use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::{Shape, Tensor}; use ort::value::{Shape, Tensor};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::borrow::Cow; use std::borrow::Cow;
use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Instant; use std::time::Instant;
use tokenizers::Tokenizer; use tokenizers::Tokenizer;
// Suppress the console window when spawning curl.exe from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft"; pub const FLORENCE_MODEL_ID: &str = "onnx-community/Florence-2-base-ft";
pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4"; pub const FLORENCE_CAPTION_MODEL_NAME: &str = "florence-2-base-ft-onnx-q4";
const ONNX_RUNTIME_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.DirectML/1.24.2";
const DIRECTML_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.AI.DirectML/1.15.4";
const ONNX_RUNTIME_DLL_FILE: &str = "onnxruntime/onnxruntime.dll";
const ONNX_RUNTIME_PROVIDERS_DLL_FILE: &str = "onnxruntime/onnxruntime_providers_shared.dll";
const DIRECTML_DLL_FILE: &str = "onnxruntime/DirectML.dll";
const CAPTION_ACCELERATION_FILE: &str = "settings/caption_acceleration.txt"; const CAPTION_ACCELERATION_FILE: &str = "settings/caption_acceleration.txt";
const CAPTION_DETAIL_FILE: &str = "settings/caption_detail.txt"; const CAPTION_DETAIL_FILE: &str = "settings/caption_detail.txt";
const ONNX_RUNTIME_FILES: &[(&str, &str, &str)] = &[
(
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime.dll",
),
(
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime_providers_shared.dll",
),
(
DIRECTML_DLL_FILE,
DIRECTML_NUGET_URL,
"bin/x64-win/DirectML.dll",
),
];
const REQUIRED_FILES: &[&str] = &[ const REQUIRED_FILES: &[&str] = &[
ONNX_RUNTIME_DLL_FILE, ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_PROVIDERS_DLL_FILE, ONNX_RUNTIME_PROVIDERS_DLL_FILE,
@@ -69,11 +38,6 @@ const REQUIRED_FILES: &[&str] = &[
"onnx/embed_tokens_fp16.onnx", "onnx/embed_tokens_fp16.onnx",
]; ];
// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// Set to `true` by `set_caption_acceleration` so the caption worker loop /// Set to `true` by `set_caption_acceleration` so the caption worker loop
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP. /// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false); pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
@@ -294,11 +258,11 @@ pub fn prepare_caption_model_with_progress(
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
if ONNX_RUNTIME_FILES if onnx_runtime::ONNX_RUNTIME_FILES
.iter() .iter()
.any(|(runtime_file, _, _)| runtime_file == file) .any(|(runtime_file, _, _)| runtime_file == file)
{ {
download_onnx_runtime_files(&local_dir)?; onnx_runtime::download_onnx_runtime_files(&local_dir)?;
completed_files = REQUIRED_FILES completed_files = REQUIRED_FILES
.iter() .iter()
.filter(|file| local_dir.join(file).exists()) .filter(|file| local_dir.join(file).exists())
@@ -344,7 +308,7 @@ pub fn probe_caption_runtime(app_data_dir: &Path) -> Result<CaptionRuntimeProbe>
} }
let local_dir = model_dir(app_data_dir); let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?; onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let tokenizer = let tokenizer =
Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?; Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
@@ -393,7 +357,7 @@ pub fn probe_caption_vision(app_data_dir: &Path, image_path: &Path) -> Result<Ca
} }
let local_dir = model_dir(app_data_dir); let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?; onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let pixels = preprocess_image(image_path)?; let pixels = preprocess_image(image_path)?;
let input_shape = vec![1, 3, 768, 768]; let input_shape = vec![1, 3, 768, 768];
let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice())) let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
@@ -438,7 +402,7 @@ impl FlorenceCaptioner {
} }
let local_dir = model_dir(app_data_dir); let local_dir = model_dir(app_data_dir);
ensure_onnx_runtime(&local_dir)?; onnx_runtime::ensure_onnx_runtime(&local_dir)?;
let tokenizer = let tokenizer =
Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?; Tokenizer::from_file(local_dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
let caption_detail = caption_detail(app_data_dir); let caption_detail = caption_detail(app_data_dir);
@@ -647,288 +611,6 @@ fn probe_vision_session(
}) })
} }
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
let mut initialized = ORT_RUNTIME_INIT
.lock()
.map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
if *initialized {
return Ok(());
}
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
if !dll_path.exists() {
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
}
ort::environment::init_from(&dll_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.with_name("phokus-florence")
.commit();
*initialized = true;
Ok(())
}
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
local_dir: &Path,
mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
if destination.exists() {
continue;
}
// Strip the "onnxruntime/" prefix for a clean label.
let label = destination_file
.rsplit('/')
.next()
.unwrap_or(destination_file);
download_nuget_file(
source_url,
archive_path,
&destination,
|downloaded, total| on_progress(label, downloaded, total),
)?;
}
Ok(())
}
/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
ONNX_RUNTIME_FILES
.iter()
.filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
.count()
}
fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
if !cfg!(target_os = "windows") {
anyhow::bail!(
"Florence-2 ONNX Runtime download is currently configured for Windows builds"
);
}
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
}
Ok(())
}
// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;
/// Resiliently download `url` to `destination` using the system `curl.exe`.
///
/// ureq's read timeout does not fire on a stalled large transfer on Windows
/// (schannel doesn't honor the socket read timeout), so a stall there hangs
/// forever. curl detects an inactivity stall (`--speed-time`), resumes from
/// the partial file (`-C -`), and retries internally — the same behavior a
/// browser gets. We monitor the `.part` file's size for the progress bar and
/// wrap curl in an outer progress-aware retry as a backstop. The partial file
/// survives an app restart, so a later retry continues from disk.
pub fn download_file_resilient(
url: &str,
destination: &Path,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = match destination.extension() {
Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
None => destination.with_extension("part"),
};
let name = destination
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
log::info!("{name}: resolving download size");
let total = remote_content_length(url);
// Reconcile any existing `.part` against the real size: exactly complete →
// finish; oversized (stale/corrupt) → discard so curl restarts cleanly
// (otherwise `curl -C -` would 416 forever).
if let Some(total) = total {
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size == total {
std::fs::rename(&part, destination)?;
return Ok(());
}
if size > total {
let _ = std::fs::remove_file(&part);
}
}
log::info!(
"{name}: downloading via curl ({} bytes)",
total
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown size".into())
);
let mut stalls = 0usize;
loop {
let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
match run_curl_download(url, &part, total, &mut on_progress) {
Ok(()) => break,
Err(error) => {
let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if after > before {
log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
stalls = 0;
} else {
stalls += 1;
log::warn!(
"{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
);
if stalls >= MAX_STALL_RETRIES {
// Discard the partial so a future attempt restarts clean
// rather than getting stuck re-resuming a bad file.
let _ = std::fs::remove_file(&part);
return Err(error);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
if let Some(total) = total {
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got < total {
anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
}
}
std::fs::rename(&part, destination)?;
Ok(())
}
/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
let mut command = Command::new("curl.exe");
command.args([
"-sL",
"-r",
"0-0",
"-D",
"-",
"-o",
"NUL",
"--connect-timeout",
"30",
"--max-time",
"30",
url,
]);
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let output = command.output().ok()?;
let headers = String::from_utf8_lossy(&output.stdout);
for line in headers.lines() {
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
if let Some(total) = rest.rsplit('/').next().map(str::trim) {
if let Ok(n) = total.parse::<u64>() {
return Some(n);
}
}
}
}
None
}
/// Run one `curl.exe` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
url: &str,
dest: &Path,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut command = Command::new("curl.exe");
command
.arg("-fSL") // fail on HTTP errors, follow redirects, show errors
.args(["-C", "-"]) // resume from the existing output file
.args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
.args(["--connect-timeout", "30"])
// Abort (then --retry resumes) if under 1 KB/s for 30s — a real
// inactivity timeout, which is what ureq couldn't deliver here.
.args(["--speed-limit", "1024", "--speed-time", "30"])
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch curl.exe (required for downloads): {e}"))?;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"curl exited with {}: {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
);
}
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
on_progress(downloaded, total);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
fn download_nuget_file(
source_url: &str,
archive_path: &str,
destination: &Path,
on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
// Download the .nupkg (a zip) resiliently, then extract the one DLL.
let package = destination.with_extension("nupkg");
download_file_resilient(source_url, &package, on_progress)?;
log::info!("extracting {archive_path} from package");
let file = std::fs::File::open(&package)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut dll = archive.by_name(archive_path)?;
let temp_destination = destination.with_extension("tmp");
{
let mut out = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut out)?;
}
std::fs::rename(&temp_destination, destination)?;
let _ = std::fs::remove_file(&package);
log::info!("extracted {archive_path}");
Ok(())
}
fn run_vision_encoder(session: &mut Session, image_path: &Path) -> Result<TensorData> { fn run_vision_encoder(session: &mut Session, image_path: &Path) -> Result<TensorData> {
let pixels = preprocess_image(image_path)?; let pixels = preprocess_image(image_path)?;
let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice())) let input = Tensor::from_array(([1usize, 3, 768, 768], pixels.into_boxed_slice()))
+372 -61
View File
@@ -8,7 +8,7 @@ use crate::db::{
use crate::embedder; use crate::embedder;
use crate::hnsw_index; use crate::hnsw_index;
use crate::indexer::{self, WatcherHandle}; use crate::indexer::{self, WatcherHandle};
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::tagger::{self, TaggerAcceleration, TaggerModel, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector; use crate::vector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet; use std::collections::HashSet;
@@ -162,6 +162,8 @@ pub struct TagSearchParams {
pub media_kind: Option<String>, pub media_kind: Option<String>,
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>, pub rating_min: Option<i64>,
/// Optional `[r, g, b]` color filter applied within the tag result set.
pub color: Option<[u8; 3]>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
} }
@@ -180,6 +182,19 @@ pub struct GetExploreTagsParams {
pub limit: Option<usize>, pub limit: Option<usize>,
} }
#[derive(Deserialize)]
pub struct GetRelatedTagsParams {
pub tag: String,
pub folder_id: Option<i64>,
pub limit: Option<usize>,
}
#[derive(Serialize)]
pub struct RelatedTagEntry {
pub tag: String,
pub shared_count: i64,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SearchTagsAutocompleteParams { pub struct SearchTagsAutocompleteParams {
pub query: String, pub query: String,
@@ -944,6 +959,7 @@ pub async fn search_images_by_tag(
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
let limit = params.limit.unwrap_or(64); let limit = params.limit.unwrap_or(64);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let color = params.color.map(|[r, g, b]| (r, g, b));
let (images, total) = db::search_images_by_tag( let (images, total) = db::search_images_by_tag(
&conn, &conn,
&params.query, &params.query,
@@ -951,6 +967,7 @@ pub async fn search_images_by_tag(
params.media_kind.as_deref(), params.media_kind.as_deref(),
params.favorites_only.unwrap_or(false), params.favorites_only.unwrap_or(false),
params.rating_min.unwrap_or(0), params.rating_min.unwrap_or(0),
color,
limit, limit,
offset, offset,
) )
@@ -1148,7 +1165,7 @@ pub async fn reset_generated_captions(
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct TagCloudEntry { pub struct VisualClusterEntry {
pub count: usize, pub count: usize,
pub representative_image_id: i64, pub representative_image_id: i64,
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
@@ -1161,48 +1178,50 @@ pub struct TagCloudEntry {
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated /// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
/// calls (including across app restarts) return instantly when the library hasn't changed. /// calls (including across app restarts) return instantly when the library hasn't changed.
#[tauri::command] #[tauri::command]
pub async fn get_tag_cloud( pub async fn get_visual_clusters(
db: State<'_, DbState>, db: State<'_, DbState>,
folder_id: Option<i64>, folder_id: Option<i64>,
) -> Result<Vec<TagCloudEntry>, String> { ) -> Result<Vec<VisualClusterEntry>, String> {
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
let n = embeddings_with_ids.len();
if n < 5 {
return Ok(vec![]);
}
// Sort by ID for stable ordering; hash both IDs and embedding bytes so that
// replacing a file and regenerating its embedding invalidates the cache even
// when the set of image IDs hasn't changed.
let mut sorted_pairs: Vec<_> = embeddings_with_ids.iter().collect();
sorted_pairs.sort_unstable_by_key(|(id, _)| *id);
let current_hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
for (id, embedding) in &sorted_pairs {
hasher.update(&id.to_le_bytes());
for val in embedding.iter() {
hasher.update(&val.to_le_bytes());
}
}
hasher.digest()
};
let folder_scope = match folder_id { let folder_scope = match folder_id {
Some(id) => format!("folder_{id}"), Some(id) => format!("folder_{id}"),
None => "all".to_string(), None => "all".to_string(),
}; };
// Try to return a valid SQLite cache // Build a cheap cache key from a hash of the embedded image-ID set plus the
// global monotonic embedding revision. The ID-set hash catches membership
// changes (add/remove, or moving an image between folders — even when the
// count is unchanged); the revision catches an image being re-embedded in
// place (same IDs, new vector). Together they validate the cache WITHOUT
// reading and unpacking every embedding blob — which for a large library is
// hundreds of MB and was the real cause of the multi-second Explore stall
// even on a cache hit.
let (count, current_hash) = {
let conn = db.get().map_err(|e| e.to_string())?;
let (count, ids_hash) =
vector::embedding_ids_signature(&conn, folder_id).map_err(|e| e.to_string())?;
let revision = vector::get_embedding_revision(&conn).map_err(|e| e.to_string())?;
let hash = {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
hasher.update(&ids_hash.to_le_bytes());
hasher.update(revision.as_bytes());
hasher.update(CLUSTER_CACHE_VERSION.as_bytes());
hasher.digest()
};
(count, hash)
};
if count < 5 {
return Ok(vec![]);
}
// Try to return a valid SQLite cache before loading any embeddings.
{ {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
if let Some(json) = db::get_tag_cloud_cache(&conn, &folder_scope, current_hash) if let Some(json) = db::get_visual_cluster_cache(&conn, &folder_scope, current_hash)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
{ {
if let Ok(entries) = serde_json::from_str::<Vec<TagCloudEntry>>(&json) { if let Ok(entries) = serde_json::from_str::<Vec<VisualClusterEntry>>(&json) {
// Reject cache entries written before image_ids were tracked — they all // Reject cache entries written before image_ids were tracked — they all
// have empty image_ids which causes "No media found" when a cluster is opened. // have empty image_ids which causes "No media found" when a cluster is opened.
if entries.iter().all(|e| !e.image_ids.is_empty()) { if entries.iter().all(|e| !e.image_ids.is_empty()) {
@@ -1212,17 +1231,32 @@ pub async fn get_tag_cloud(
} }
} }
// Cache miss — run k-means // Cache miss — now pay the cost of loading embeddings and running k-means.
let embeddings_with_ids = {
let conn = db.get().map_err(|e| e.to_string())?;
vector::get_all_image_embeddings_with_ids(&conn, folder_id).map_err(|e| e.to_string())?
};
if embeddings_with_ids.len() < 5 {
return Ok(vec![]);
}
let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect(); let ids: Vec<i64> = embeddings_with_ids.iter().map(|(id, _)| *id).collect();
let n = embeddings_with_ids.len();
let points: Vec<Vec<f32>> = embeddings_with_ids let points: Vec<Vec<f32>> = embeddings_with_ids
.into_iter() .into_iter()
.map(|(_, emb)| emb) .map(|(_, emb)| emb)
.collect(); .collect();
let k = (n / 20).clamp(5, 30); let k = (n / 20).clamp(5, 30);
let kmeans_start = std::time::Instant::now();
let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40); let (centroids, cluster_counts, assignments) = kmeans_cosine(&points, k, 40);
log::debug!(
"visual-cluster clustering: {n} embeddings, k={k}, sampled={} → {:.2?}",
n > MAX_KMEANS_SAMPLE,
kmeans_start.elapsed(),
);
let mut entries: Vec<TagCloudEntry> = Vec::new(); let mut entries: Vec<VisualClusterEntry> = Vec::new();
let mut order: Vec<usize> = (0..k).collect(); let mut order: Vec<usize> = (0..k).collect();
order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a])); order.sort_unstable_by(|&a, &b| cluster_counts[b].cmp(&cluster_counts[a]));
@@ -1255,7 +1289,7 @@ pub async fn get_tag_cloud(
.ok() .ok()
.and_then(|img| img.thumbnail_path); .and_then(|img| img.thumbnail_path);
entries.push(TagCloudEntry { entries.push(VisualClusterEntry {
count, count,
representative_image_id: best_id, representative_image_id: best_id,
thumbnail_path, thumbnail_path,
@@ -1263,9 +1297,12 @@ pub async fn get_tag_cloud(
}); });
} }
// Persist to SQLite — ignore write errors (cache is best-effort) // Persist to SQLite. The cache is best-effort, but a silent write failure here
// would defeat the whole optimization (every visit would recompute), so log it.
if let Ok(json) = serde_json::to_string(&entries) { if let Ok(json) = serde_json::to_string(&entries) {
let _ = db::set_tag_cloud_cache(&conn, &folder_scope, current_hash, &json); if let Err(e) = db::set_visual_cluster_cache(&conn, &folder_scope, current_hash, &json) {
log::warn!("failed to persist visual-cluster cache for {folder_scope}: {e}");
}
} }
Ok(entries) Ok(entries)
@@ -1277,7 +1314,28 @@ pub async fn get_explore_tags(
params: GetExploreTagsParams, params: GetExploreTagsParams,
) -> Result<Vec<ExploreTagEntry>, String> { ) -> Result<Vec<ExploreTagEntry>, String> {
let conn = db.get().map_err(|e| e.to_string())?; let conn = db.get().map_err(|e| e.to_string())?;
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(180))
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_related_tags(
db: State<'_, DbState>,
params: GetRelatedTagsParams,
) -> Result<Vec<RelatedTagEntry>, String> {
let tag = params.tag.trim();
if tag.is_empty() {
return Ok(vec![]);
}
let conn = db.get().map_err(|e| e.to_string())?;
db::get_related_tags(&conn, tag, params.folder_id, params.limit.unwrap_or(16))
.map(|entries| {
entries
.into_iter()
.map(|(tag, shared_count)| RelatedTagEntry { tag, shared_count })
.collect()
})
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
@@ -1799,34 +1857,139 @@ fn normalize(v: &mut [f32]) {
} }
} }
/// When the library is larger than this, the iterative k-means centroid search
/// runs on a deterministic, evenly-strided sample of this many embeddings rather
/// than the full set; every image is then assigned to its nearest centroid in a
/// single parallel pass. The centroid search is the expensive O(n·k·dim·iters)
/// part, so sampling it keeps the (statistically equivalent) clustering fast on
/// large libraries while the full assignment still places every image.
const MAX_KMEANS_SAMPLE: usize = 3000;
/// Bumped whenever the clustering algorithm changes so persisted visual-cluster caches
/// computed by an older algorithm are invalidated and recomputed on next view.
const CLUSTER_CACHE_VERSION: &str = "sampled-v2";
/// Cosine k-means. For large inputs the iterative centroid search runs on a
/// strided sample (see [`MAX_KMEANS_SAMPLE`]); the final assignment of every
/// point to its nearest centroid always covers the full input, in parallel.
fn kmeans_cosine( fn kmeans_cosine(
points: &[Vec<f32>], points: &[Vec<f32>],
k: usize, k: usize,
max_iter: usize, max_iter: usize,
) -> (Vec<Vec<f32>>, Vec<usize>, Vec<usize>) { ) -> (Vec<Vec<f32>>, Vec<usize>, Vec<usize>) {
let n = points.len(); let n = points.len();
let dim = points[0].len();
// Deterministic k-means++ init: spread centroids as far apart as possible // Deterministic, evenly-distributed sample for the centroid search. For n at
// or below the cap this is just the full set (a clone), so behaviour is
// unchanged on small/medium libraries.
let sample: Vec<Vec<f32>> = if n > MAX_KMEANS_SAMPLE {
let step = n / MAX_KMEANS_SAMPLE;
points
.iter()
.step_by(step)
.take(MAX_KMEANS_SAMPLE)
.cloned()
.collect()
} else {
points.to_vec()
};
let centroids = kmeans_centroids(&sample, k, max_iter);
// Assign every point — not just the sample — to its nearest centroid.
let assignments = assign_to_centroids(points, &centroids);
let mut counts = vec![0usize; k];
for &a in &assignments {
counts[a] += 1;
}
(centroids, counts, assignments)
}
/// Small deterministic PRNG (SplitMix64) so k-means++ seeding is reproducible
/// across runs without pulling in the `rand` crate.
struct DetRng(u64);
impl DetRng {
fn new(seed: u64) -> Self {
DetRng(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
/// Uniform f64 in [0, 1).
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
/// Deterministic k-means++ seeding: the first centroid is the middle point, then
/// each subsequent centroid is drawn from the remaining points with probability
/// proportional to its squared distance to the nearest chosen centroid (D²
/// weighting). For unit-normalized embeddings the squared Euclidean distance is
/// proportional to `1 - cos`, so that is used as the weight. Unlike greedy
/// farthest-point seeding this places proportionally more centroids in dense
/// regions, preventing a single centroid from absorbing the whole dominant mode.
fn kmeans_pp_seed(points: &[Vec<f32>], k: usize) -> Vec<Vec<f32>> {
let n = points.len();
let mut rng = DetRng::new(0x5EED_1234_ABCD_0001);
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k); let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
centroids.push(points[n / 2].clone()); centroids.push(points[n / 2].clone());
// d2[i] = distance from point i to its nearest chosen centroid (∝ squared
// Euclidean for unit vectors), kept up to date as centroids are added.
let mut d2 = vec![f32::MAX; n];
for _ in 1..k { for _ in 1..k {
let next = points let last = centroids.last().unwrap();
.iter() for (i, p) in points.iter().enumerate() {
.map(|p| { let dist = (1.0 - dot(p, last)).max(0.0);
let best_sim = centroids if dist < d2[i] {
.iter() d2[i] = dist;
.map(|c| dot(p, c)) }
.fold(f32::NEG_INFINITY, f32::max); }
1.0 - best_sim // distance = 1 - cosine_similarity
}) let total: f64 = d2.iter().map(|&w| w as f64).sum();
.enumerate() if total <= 0.0 {
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) // Remaining points coincide with chosen centroids; pad deterministically.
.map(|(i, _)| i) centroids.push(points[n / 2].clone());
.unwrap_or(0); continue;
centroids.push(points[next].clone()); }
let mut target = rng.next_f64() * total;
let mut idx = n - 1;
for (i, &w) in d2.iter().enumerate() {
target -= w as f64;
if target <= 0.0 {
idx = i;
break;
}
}
centroids.push(points[idx].clone());
} }
centroids
}
/// Runs deterministic k-means++ seeding + Lloyd iterations on `points`, returning
/// the final normalized centroids. Operates only on the slice it is given — the
/// caller passes a sample for large libraries.
fn kmeans_centroids(points: &[Vec<f32>], k: usize, max_iter: usize) -> Vec<Vec<f32>> {
let n = points.len();
let dim = points[0].len();
// Density-aware k-means++ seeding (see kmeans_pp_seed). Crucially NOT greedy
// farthest-point seeding, which picks outliers and — on a sampled large
// library — leaves the dense core under-seeded so one centroid swallows a huge
// share of the (visually generic) images.
let mut centroids = kmeans_pp_seed(points, k);
let mut assignments = vec![0usize; n]; let mut assignments = vec![0usize; n];
for _ in 0..max_iter { for _ in 0..max_iter {
@@ -1867,12 +2030,26 @@ fn kmeans_cosine(
} }
} }
let mut counts = vec![0usize; k]; centroids
for &a in &assignments { }
counts[a] += 1;
}
(centroids, counts, assignments) /// Assigns each point to its nearest centroid (max cosine similarity) in parallel.
/// This is the only step that touches every embedding, so parallelising it is
/// where the multi-core speed-up on large libraries comes from.
fn assign_to_centroids(points: &[Vec<f32>], centroids: &[Vec<f32>]) -> Vec<usize> {
use rayon::prelude::*;
points
.par_iter()
.map(|p| {
centroids
.iter()
.enumerate()
.map(|(j, c)| (j, dot(p, c)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(j, _)| j)
.unwrap_or(0)
})
.collect()
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -1913,6 +2090,7 @@ pub struct FolderWorkerStates {
#[tauri::command] #[tauri::command]
pub async fn set_worker_paused( pub async fn set_worker_paused(
app: AppHandle,
db: State<'_, DbState>, db: State<'_, DbState>,
worker: String, worker: String,
folder_id: i64, folder_id: i64,
@@ -1929,6 +2107,9 @@ pub async fn set_worker_paused(
db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id) db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
} }
if let Err(error) = persist_worker_pauses_if_enabled(&app) {
log::warn!("Failed to persist worker pause state: {error}");
}
Ok(()) Ok(())
} }
@@ -1970,9 +2151,15 @@ pub struct SetTaggerAccelerationParams {
pub acceleration: TaggerAcceleration, pub acceleration: TaggerAcceleration,
} }
#[derive(Deserialize)]
pub struct SetTaggerModelParams {
pub model: TaggerModel,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetTaggerThresholdParams { pub struct SetTaggerThresholdParams {
pub threshold: f32, pub threshold: f32,
pub model: Option<TaggerModel>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -1993,6 +2180,12 @@ pub struct ClearTaggingJobsParams {
pub folder_ids: Option<Vec<i64>>, pub folder_ids: Option<Vec<i64>>,
} }
#[derive(Deserialize)]
pub struct ResetAiTagsParams {
pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct GetImageTagsParams { pub struct GetImageTagsParams {
pub image_id: i64, pub image_id: i64,
@@ -2030,6 +2223,21 @@ pub async fn set_tagger_acceleration(
tagger::set_tagger_acceleration(&app_dir, params.acceleration).map_err(|e| e.to_string()) tagger::set_tagger_acceleration(&app_dir, params.acceleration).map_err(|e| e.to_string())
} }
#[tauri::command]
pub async fn get_tagger_model(app: AppHandle) -> Result<TaggerModel, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(tagger::tagger_model(&app_dir))
}
#[tauri::command]
pub async fn set_tagger_model(
app: AppHandle,
params: SetTaggerModelParams,
) -> Result<TaggerModel, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
tagger::set_tagger_model(&app_dir, params.model).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn probe_tagger_runtime(app: AppHandle) -> Result<TaggerRuntimeProbe, String> { pub async fn probe_tagger_runtime(app: AppHandle) -> Result<TaggerRuntimeProbe, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
@@ -2051,7 +2259,11 @@ pub async fn set_tagger_threshold(
params: SetTaggerThresholdParams, params: SetTaggerThresholdParams,
) -> Result<f32, String> { ) -> Result<f32, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
tagger::set_tagger_threshold(&app_dir, params.threshold).map_err(|e| e.to_string()) match params.model {
Some(model) => tagger::set_tagger_threshold_for_model(&app_dir, model, params.threshold),
None => tagger::set_tagger_threshold(&app_dir, params.threshold),
}
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
@@ -2176,6 +2388,42 @@ pub async fn clear_tagging_jobs(
Ok(n) Ok(n)
} }
#[tauri::command]
pub async fn reset_ai_tags(
app: AppHandle,
db: State<'_, DbState>,
params: ResetAiTagsParams,
) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let requested_folder_ids = params.folder_ids.unwrap_or_default();
let (n, folder_ids): (usize, Vec<i64>) =
match (params.folder_id, requested_folder_ids.is_empty()) {
(Some(id), _) => (
db::reset_ai_tags(&conn, Some(id)).map_err(|e| e.to_string())?,
vec![id],
),
(None, false) => {
let mut total = 0usize;
for &folder_id in &requested_folder_ids {
total +=
db::reset_ai_tags(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
}
(total, requested_folder_ids)
}
(None, true) => (
db::reset_ai_tags(&conn, None).map_err(|e| e.to_string())?,
db::get_folders(&conn)
.map_err(|e| e.to_string())?
.into_iter()
.map(|f| f.id)
.collect(),
),
};
drop(conn);
indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true);
Ok(n)
}
#[tauri::command] #[tauri::command]
pub async fn get_image_tags( pub async fn get_image_tags(
db: State<'_, DbState>, db: State<'_, DbState>,
@@ -2428,6 +2676,8 @@ pub async fn bulk_remove_tag(
const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt"; const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt";
const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt"; const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt";
const WORKER_PAUSES_PERSIST_FILE: &str = "settings/worker_pauses_persist.txt";
const WORKER_PAUSES_FILE: &str = "settings/worker_pauses.json";
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetTaggingQueueScopeParams { pub struct SetTaggingQueueScopeParams {
@@ -2499,6 +2749,67 @@ pub async fn set_tagging_queue_folder_ids(
Ok(()) Ok(())
} }
fn worker_pause_persistence_enabled(app_dir: &Path) -> bool {
std::fs::read_to_string(app_dir.join(WORKER_PAUSES_PERSIST_FILE))
.map(|value| value.trim() == "true")
.unwrap_or(false)
}
fn write_worker_pause_snapshot(app_dir: &Path) -> Result<(), String> {
let path = app_dir.join(WORKER_PAUSES_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&indexer::snapshot_worker_paused_states())
.map_err(|e| e.to_string())?;
std::fs::write(path, json).map_err(|e| e.to_string())
}
fn persist_worker_pauses_if_enabled(app: &AppHandle) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
if worker_pause_persistence_enabled(&app_dir) {
write_worker_pause_snapshot(&app_dir)?;
}
Ok(())
}
pub fn restore_persisted_worker_pauses(app_dir: &Path) {
if !worker_pause_persistence_enabled(app_dir) {
return;
}
let path = app_dir.join(WORKER_PAUSES_FILE);
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
match serde_json::from_str::<indexer::PersistedPausedWorkerFolders>(&content) {
Ok(states) => indexer::replace_worker_paused_states(states),
Err(error) => log::warn!("Failed to restore persisted worker pauses: {error}"),
}
}
#[tauri::command]
pub async fn get_worker_pauses_persist(app: AppHandle) -> Result<bool, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
Ok(worker_pause_persistence_enabled(&app_dir))
}
#[tauri::command]
pub async fn set_worker_pauses_persist(app: AppHandle, persist: bool) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(WORKER_PAUSES_PERSIST_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(path, if persist { "true" } else { "false" }).map_err(|e| e.to_string())?;
if persist {
write_worker_pause_snapshot(&app_dir)?;
} else {
let _ = std::fs::remove_file(app_dir.join(WORKER_PAUSES_FILE));
}
Ok(())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// App data folder // App data folder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+754 -33
View File
@@ -1,4 +1,4 @@
use crate::vector; use crate::{ai_tag_filter, vector};
use anyhow::Result; use anyhow::Result;
use r2d2::Pool; use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager; use r2d2_sqlite::SqliteConnectionManager;
@@ -146,6 +146,8 @@ pub struct ExploreTagEntry {
pub count: i64, pub count: i64,
pub representative_image_id: i64, pub representative_image_id: i64,
pub thumbnail_path: Option<String>, pub thumbnail_path: Option<String>,
pub has_ai_source: bool,
pub has_user_source: bool,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -267,12 +269,16 @@ pub fn migrate(conn: &Connection) -> Result<()> {
UNIQUE(image_id, tag) UNIQUE(image_id, tag)
); );
CREATE TABLE IF NOT EXISTS tag_cloud_cache ( CREATE TABLE IF NOT EXISTS visual_cluster_cache (
folder_scope TEXT PRIMARY KEY, folder_scope TEXT PRIMARY KEY,
image_ids_hash INTEGER NOT NULL, image_ids_hash INTEGER NOT NULL,
entries_json TEXT NOT NULL, entries_json TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- Renamed from the misnamed tag_cloud_cache (it caches visual clusters,
-- not tags). The cache is disposable and was already invalidated by a
-- cluster-algorithm version bump, so the old table is simply dropped.
DROP TABLE IF EXISTS tag_cloud_cache;
CREATE TABLE IF NOT EXISTS duplicate_scan_cache ( CREATE TABLE IF NOT EXISTS duplicate_scan_cache (
folder_scope TEXT PRIMARY KEY, folder_scope TEXT PRIMARY KEY,
@@ -297,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id); CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source); CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id);
CREATE TABLE IF NOT EXISTS albums ( CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -378,6 +385,42 @@ pub fn migrate(conn: &Connection) -> Result<()> {
)?; )?;
vector::migrate(conn)?; vector::migrate(conn)?;
remove_filtered_ai_tags(conn)?;
Ok(())
}
fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("SELECT id, tag FROM image_tags WHERE source = 'ai'")?;
let filtered_ids = stmt
.query_map([], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?
.filter_map(|row| match row {
Ok((id, tag)) if ai_tag_filter::is_removed_ai_tag(&tag) => Some(Ok(id)),
Ok(_) => None,
Err(error) => Some(Err(error)),
})
.collect::<rusqlite::Result<Vec<_>>>()?;
drop(stmt);
if filtered_ids.is_empty() {
return Ok(());
}
let tx = conn.unchecked_transaction()?;
{
let mut delete_stmt = tx.prepare("DELETE FROM image_tags WHERE id = ?1")?;
for id in &filtered_ids {
delete_stmt.execute([id])?;
}
}
tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?;
log::info!(
"Removed {} filtered AI tag(s) from existing library data",
filtered_ids.len()
);
Ok(()) Ok(())
} }
@@ -1261,6 +1304,16 @@ fn media_kind_clause(include_videos: bool) -> &'static str {
} }
} }
/// Escapes `%`, `_`, and `\` so a user-supplied string can be safely embedded
/// in a `LIKE` pattern (paired with `ESCAPE '\\'` in the query) without the
/// wildcards being interpreted literally.
fn escape_like_pattern(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
/// True if any claimable (pending, non-excluded) jobs exist in `job_table`. /// True if any claimable (pending, non-excluded) jobs exist in `job_table`.
/// Used by lower-priority workers to defer to higher-priority queues. /// Used by lower-priority workers to defer to higher-priority queues.
/// `extra_predicate` narrows the image join (e.g. videos only for metadata). /// `extra_predicate` narrows the image join (e.g. videos only for metadata).
@@ -2084,7 +2137,7 @@ pub fn get_images(
_ => "modified_at DESC NULLS LAST", _ => "modified_at DESC NULLS LAST",
}; };
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{}%", escape_like_pattern(value)));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only); let tagging_failed_flag = i64::from(tagging_failed_only);
@@ -2100,7 +2153,7 @@ pub fn get_images(
ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error ai_rating, ai_tagger_model, ai_tagged_at, ai_tagger_error
FROM images FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2) AND (?2 IS NULL OR filename LIKE ?2 ESCAPE '\\')
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
@@ -2151,7 +2204,7 @@ pub fn count_images(
tagging_failed_only: bool, tagging_failed_only: bool,
color: Option<(u8, u8, u8)>, color: Option<(u8, u8, u8)>,
) -> Result<i64> { ) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{}%", escape_like_pattern(value)));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
@@ -2163,7 +2216,7 @@ pub fn count_images(
let count = conn.query_row( let count = conn.query_row(
"SELECT COUNT(*) FROM images "SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
AND (?2 IS NULL OR filename LIKE ?2) AND (?2 IS NULL OR filename LIKE ?2 ESCAPE '\\')
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
@@ -2204,6 +2257,7 @@ pub fn search_images_by_tag(
media_kind: Option<&str>, media_kind: Option<&str>,
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
color: Option<(u8, u8, u8)>,
limit: usize, limit: usize,
offset: usize, offset: usize,
) -> Result<(Vec<ImageRecord>, usize)> { ) -> Result<(Vec<ImageRecord>, usize)> {
@@ -2212,6 +2266,10 @@ pub fn search_images_by_tag(
return Ok((Vec::new(), 0)); return Ok((Vec::new(), 0));
} }
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
// Total count (for pagination) // Total count (for pagination)
let total: usize = conn.query_row( let total: usize = conn.query_row(
@@ -2222,13 +2280,25 @@ pub fn search_images_by_tag(
AND (?2 IS NULL OR i.media_kind = ?2) AND (?2 IS NULL OR i.media_kind = ?2)
AND (?3 = 0 OR i.favorite = 1) AND (?3 = 0 OR i.favorite = 1)
AND i.rating >= ?4 AND i.rating >= ?4
AND LOWER(TRIM(t.tag)) = ?5", AND LOWER(TRIM(t.tag)) = ?5
AND (?6 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = i.id
AND c.weight >= ?11
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
))",
params![ params![
folder_id, folder_id,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min, rating_min,
normalized_query normalized_query,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
], ],
|row| row.get::<_, i64>(0), |row| row.get::<_, i64>(0),
)? as usize; )? as usize;
@@ -2242,8 +2312,14 @@ pub fn search_images_by_tag(
AND (?3 = 0 OR i.favorite = 1) AND (?3 = 0 OR i.favorite = 1)
AND i.rating >= ?4 AND i.rating >= ?4
AND LOWER(TRIM(t.tag)) = ?5 AND LOWER(TRIM(t.tag)) = ?5
AND (?6 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = i.id
AND c.weight >= ?11
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
))
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
LIMIT ?6 OFFSET ?7", LIMIT ?12 OFFSET ?13",
)?; )?;
let image_ids = stmt let image_ids = stmt
@@ -2254,6 +2330,12 @@ pub fn search_images_by_tag(
favorites_flag, favorites_flag,
rating_min, rating_min,
normalized_query, normalized_query,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT,
limit as i64, limit as i64,
offset as i64 offset as i64
], ],
@@ -2270,13 +2352,15 @@ pub fn search_tags_autocomplete(
folder_id: Option<i64>, folder_id: Option<i64>,
limit: usize, limit: usize,
) -> Result<Vec<ExploreTagEntry>> { ) -> Result<Vec<ExploreTagEntry>> {
let pattern = format!("%{}%", query.to_lowercase()); let pattern = format!("%{}%", escape_like_pattern(&query.to_lowercase()));
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id,
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
FROM image_tags t FROM image_tags t
JOIN images i ON i.id = t.image_id JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1) WHERE (?1 IS NULL OR i.folder_id = ?1)
AND LOWER(t.tag) LIKE ?2 AND LOWER(t.tag) LIKE ?2 ESCAPE '\\'
GROUP BY t.tag GROUP BY t.tag
ORDER BY tag_count DESC, t.tag ASC ORDER BY tag_count DESC, t.tag ASC
LIMIT ?3", LIMIT ?3",
@@ -2288,6 +2372,8 @@ pub fn search_tags_autocomplete(
count: row.get(1)?, count: row.get(1)?,
representative_image_id: row.get(2)?, representative_image_id: row.get(2)?,
thumbnail_path: None, // skip per-suggestion thumbnail for speed thumbnail_path: None, // skip per-suggestion thumbnail for speed
has_ai_source: row.get::<_, i64>(3)? != 0,
has_user_source: row.get::<_, i64>(4)? != 0,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -2371,28 +2457,42 @@ pub fn get_explore_tags(
limit: usize, limit: usize,
) -> Result<Vec<ExploreTagEntry>> { ) -> Result<Vec<ExploreTagEntry>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id "WITH tag_counts AS (
FROM image_tags t SELECT t.tag,
JOIN images i ON i.id = t.image_id COUNT(DISTINCT t.image_id) AS tag_count,
WHERE (?1 IS NULL OR i.folder_id = ?1) MIN(t.image_id) AS representative_image_id,
GROUP BY t.tag MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
HAVING COUNT(DISTINCT t.image_id) >= 1 MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
ORDER BY tag_count DESC, t.tag ASC FROM image_tags t
LIMIT ?2", JOIN images i ON i.id = t.image_id
WHERE (?1 IS NULL OR i.folder_id = ?1)
GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 1
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2
)
SELECT c.tag,
c.tag_count,
c.representative_image_id,
i.thumbnail_path,
c.has_ai_source,
c.has_user_source
FROM tag_counts c
JOIN images i ON i.id = c.representative_image_id
ORDER BY c.tag_count DESC, c.tag ASC",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![folder_id, limit as i64], |row| { .query_map(params![folder_id, limit as i64], |row| {
let representative_image_id = row.get::<_, i64>(2)?; let representative_image_id = row.get::<_, i64>(2)?;
let thumbnail_path = get_image_by_id(conn, representative_image_id)
.ok()
.and_then(|image| image.thumbnail_path);
Ok(ExploreTagEntry { Ok(ExploreTagEntry {
tag: row.get(0)?, tag: row.get(0)?,
count: row.get(1)?, count: row.get(1)?,
representative_image_id, representative_image_id,
thumbnail_path, thumbnail_path: row.get(3)?,
has_ai_source: row.get::<_, i64>(4)? != 0,
has_user_source: row.get::<_, i64>(5)? != 0,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -2400,6 +2500,34 @@ pub fn get_explore_tags(
Ok(rows) Ok(rows)
} }
pub fn get_related_tags(
conn: &Connection,
tag: &str,
folder_id: Option<i64>,
limit: usize,
) -> Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT other.tag, COUNT(DISTINCT other.image_id) AS shared_count
FROM image_tags base
JOIN image_tags other ON other.image_id = base.image_id
JOIN images i ON i.id = base.image_id
WHERE base.tag = ?1
AND other.tag != base.tag
AND (?2 IS NULL OR i.folder_id = ?2)
GROUP BY other.tag
ORDER BY shared_count DESC, other.tag ASC
LIMIT ?3",
)?;
let rows = stmt
.query_map(params![tag, folder_id, limit as i64], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
type FailedEmbeddingRow = (i64, String, String, Option<String>); type FailedEmbeddingRow = (i64, String, String, Option<String>);
pub fn get_failed_embedding_images( pub fn get_failed_embedding_images(
@@ -2735,9 +2863,9 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
)?; )?;
// …then drop the now-duplicate leftovers still under the old name. // …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?; tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename // The visual-cluster cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it. // wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?; tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }
@@ -2746,11 +2874,115 @@ pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> { pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64; let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?; tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?; tx.commit()?;
Ok(removed) Ok(removed)
} }
pub fn reset_ai_tags(conn: &Connection, folder_id: Option<i64>) -> Result<usize> {
let image_ids = match folder_id {
Some(folder_id) => {
let mut stmt = conn.prepare(
"SELECT DISTINCT i.id
FROM images i
LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai'
LEFT JOIN tagging_jobs j ON j.image_id = i.id
WHERE i.folder_id = ?1
AND i.media_kind = 'image'
AND (
t.id IS NOT NULL
OR i.ai_rating IS NOT NULL
OR i.ai_tagger_model IS NOT NULL
OR i.ai_tagged_at IS NOT NULL
OR i.ai_tagger_error IS NOT NULL
OR j.image_id IS NOT NULL
)",
)?;
let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
None => {
let mut stmt = conn.prepare(
"SELECT DISTINCT i.id
FROM images i
LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai'
LEFT JOIN tagging_jobs j ON j.image_id = i.id
WHERE i.media_kind = 'image'
AND (
t.id IS NOT NULL
OR i.ai_rating IS NOT NULL
OR i.ai_tagger_model IS NOT NULL
OR i.ai_tagged_at IS NOT NULL
OR i.ai_tagger_error IS NOT NULL
OR j.image_id IS NOT NULL
)",
)?;
let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?;
rows.collect::<rusqlite::Result<Vec<_>>>()?
}
};
let tx = conn.unchecked_transaction()?;
match folder_id {
Some(folder_id) => {
tx.execute(
"DELETE FROM image_tags
WHERE source = 'ai'
AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
[folder_id],
)?;
tx.execute(
"UPDATE images
SET ai_rating = NULL,
ai_tagger_model = NULL,
ai_tagged_at = NULL,
ai_tagger_error = NULL
WHERE folder_id = ?1
AND media_kind = 'image'",
[folder_id],
)?;
tx.execute(
"UPDATE tagging_jobs
SET status = 'cancelled', last_error = NULL, updated_at = datetime('now')
WHERE status = 'processing'
AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
[folder_id],
)?;
tx.execute(
"DELETE FROM tagging_jobs
WHERE status NOT IN ('processing', 'cancelled')
AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)",
[folder_id],
)?;
}
None => {
tx.execute("DELETE FROM image_tags WHERE source = 'ai'", [])?;
tx.execute(
"UPDATE images
SET ai_rating = NULL,
ai_tagger_model = NULL,
ai_tagged_at = NULL,
ai_tagger_error = NULL
WHERE media_kind = 'image'",
[],
)?;
tx.execute(
"UPDATE tagging_jobs
SET status = 'cancelled', last_error = NULL, updated_at = datetime('now')
WHERE status = 'processing'",
[],
)?;
tx.execute(
"DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')",
[],
)?;
}
}
tx.execute("DELETE FROM visual_cluster_cache", [])?;
tx.commit()?;
Ok(image_ids.len())
}
pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> { pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> {
let inserted = conn.execute( let inserted = conn.execute(
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
@@ -2919,14 +3151,14 @@ fn map_image_row(row: &Row<'_>) -> rusqlite::Result<ImageRecord> {
}) })
} }
/// Returns cached tag-cloud entries if the stored hash matches `current_hash`. /// Returns cached visual-cluster entries if the stored hash matches `current_hash`.
pub fn get_tag_cloud_cache( pub fn get_visual_cluster_cache(
conn: &Connection, conn: &Connection,
folder_scope: &str, folder_scope: &str,
current_hash: u64, current_hash: u64,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let result: rusqlite::Result<(i64, String)> = conn.query_row( let result: rusqlite::Result<(i64, String)> = conn.query_row(
"SELECT image_ids_hash, entries_json FROM tag_cloud_cache WHERE folder_scope = ?1", "SELECT image_ids_hash, entries_json FROM visual_cluster_cache WHERE folder_scope = ?1",
params![folder_scope], params![folder_scope],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
); );
@@ -2938,15 +3170,15 @@ pub fn get_tag_cloud_cache(
} }
} }
/// Upserts the tag-cloud cache for the given scope. /// Upserts the visual-cluster cache for the given scope.
pub fn set_tag_cloud_cache( pub fn set_visual_cluster_cache(
conn: &Connection, conn: &Connection,
folder_scope: &str, folder_scope: &str,
image_ids_hash: u64, image_ids_hash: u64,
entries_json: &str, entries_json: &str,
) -> Result<()> { ) -> Result<()> {
conn.execute( conn.execute(
"INSERT INTO tag_cloud_cache (folder_scope, image_ids_hash, entries_json, created_at) "INSERT INTO visual_cluster_cache (folder_scope, image_ids_hash, entries_json, created_at)
VALUES (?1, ?2, ?3, datetime('now')) VALUES (?1, ?2, ?3, datetime('now'))
ON CONFLICT(folder_scope) DO UPDATE SET ON CONFLICT(folder_scope) DO UPDATE SET
image_ids_hash = excluded.image_ids_hash, image_ids_hash = excluded.image_ids_hash,
@@ -3043,3 +3275,492 @@ fn folder_exclusion_clause(
.join(","); .join(",");
format!("AND {image_alias}.folder_id NOT IN ({id_list})") format!("AND {image_alias}.folder_id NOT IN ({id_list})")
} }
/// Shared fixtures for unit tests across modules (db, vector, …).
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
pub(crate) fn test_conn() -> Connection {
vector::register_sqlite_vec();
let conn = Connection::open_in_memory().unwrap();
// The r2d2 pool customizer normally sets this; mirror it so FK cascades
// (album deletion, folder deletion) behave like production.
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
migrate(&conn).unwrap();
vector::migrate(&conn).unwrap();
conn
}
pub(crate) fn test_image(folder_id: i64, path: &str) -> ImageRecord {
ImageRecord {
id: 0,
folder_id,
path: path.to_string(),
filename: path.rsplit('/').next().unwrap_or(path).to_string(),
thumbnail_path: None,
width: Some(100),
height: Some(100),
file_size: 1024,
created_at: None,
modified_at: Some("2026-01-01T00:00:00Z".into()),
taken_at: None,
mime_type: "image/jpeg".into(),
media_kind: "image".into(),
duration_ms: None,
video_codec: None,
audio_codec: None,
metadata_updated_at: None,
metadata_error: None,
favorite: false,
rating: 0,
embedding_status: "pending".into(),
embedding_model: None,
embedding_updated_at: None,
embedding_error: None,
generated_caption: None,
caption_model: None,
caption_updated_at: None,
caption_error: None,
ai_rating: None,
ai_tagger_model: None,
ai_tagged_at: None,
ai_tagger_error: None,
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::{test_conn, test_image};
use super::*;
#[test]
fn escape_like_pattern_escapes_wildcards_and_backslash() {
assert_eq!(escape_like_pattern("50%off"), "50\\%off");
assert_eq!(escape_like_pattern("a_b"), "a\\_b");
assert_eq!(escape_like_pattern(r"C:\images"), r"C:\\images");
assert_eq!(escape_like_pattern("50%_x\\"), "50\\%\\_x\\\\");
assert_eq!(escape_like_pattern("plain text"), "plain text");
}
#[test]
fn insert_folder_is_idempotent_per_path() {
let conn = test_conn();
let first = insert_folder(&conn, "C:/media", "media").unwrap();
let second = insert_folder(&conn, "C:/media", "media again").unwrap();
assert_eq!(first, second);
insert_folder(&conn, "C:/other", "other").unwrap();
let folders = get_folders(&conn).unwrap();
assert_eq!(folders.len(), 2);
assert_eq!(folders[0].name, "media");
}
#[test]
fn upsert_image_updates_in_place_and_preserves_user_state() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/media", "media").unwrap();
let mut img = test_image(folder_id, "C:/media/a.jpg");
let id = upsert_image(&conn, &img).unwrap();
// Simulate user state + AI state on the stored row.
conn.execute(
"UPDATE images SET favorite = 1, rating = 4, ai_tagger_model = 'wd' WHERE id = ?1",
[id],
)
.unwrap();
add_user_tag(&conn, id, "keeper").unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
[id],
)
.unwrap();
img.file_size = 2048;
let same_id = upsert_image(&conn, &img).unwrap();
assert_eq!(same_id, id);
let stored = get_image_by_id(&conn, id).unwrap();
assert_eq!(stored.file_size, 2048);
// favorite/rating survive re-index; AI tag state is invalidated.
assert!(stored.favorite);
assert_eq!(stored.rating, 4);
assert_eq!(stored.ai_tagger_model, None);
let tags = get_image_tags(&conn, id).unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].tag, "keeper");
assert_eq!(tags[0].source, "user");
}
#[test]
fn get_images_applies_filters_and_agrees_with_count() {
let conn = test_conn();
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
upsert_image(&conn, &test_image(folder_a, "C:/a/cat.jpg")).unwrap();
let dog_id = upsert_image(&conn, &test_image(folder_a, "C:/a/dog.jpg")).unwrap();
let mut video = test_image(folder_b, "C:/b/clip.mp4");
video.media_kind = "video".into();
video.embedding_status = "failed".into();
upsert_image(&conn, &video).unwrap();
conn.execute(
"UPDATE images SET favorite = 1, rating = 5 WHERE id = ?1",
[dog_id],
)
.unwrap();
let no_filter = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(no_filter.len(), 3);
assert_eq!(
count_images(&conn, None, None, None, false, 0, false, false, None).unwrap(),
3
);
let by_folder = get_images(
&conn,
Some(folder_a),
None,
None,
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(by_folder.len(), 2);
let by_search = get_images(
&conn,
None,
Some("cat"),
None,
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(by_search.len(), 1);
assert_eq!(by_search[0].filename, "cat.jpg");
let videos = get_images(
&conn,
None,
None,
Some("video"),
false,
0,
false,
false,
None,
"name_asc",
0,
100,
)
.unwrap();
assert_eq!(videos.len(), 1);
let favorites = get_images(
&conn, None, None, None, true, 0, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(favorites.len(), 1);
assert_eq!(favorites[0].filename, "dog.jpg");
let rated = get_images(
&conn, None, None, None, false, 3, false, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(rated.len(), 1);
let failed = get_images(
&conn, None, None, None, false, 0, true, false, None, "name_asc", 0, 100,
)
.unwrap();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0].filename, "clip.mp4");
// Pagination: page size 2 then the remaining 1.
let page_one = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 0, 2,
)
.unwrap();
let page_two = get_images(
&conn, None, None, None, false, 0, false, false, None, "name_asc", 2, 2,
)
.unwrap();
assert_eq!(page_one.len(), 2);
assert_eq!(page_two.len(), 1);
assert_eq!(page_one[0].filename, "cat.jpg");
assert_eq!(page_two[0].filename, "dog.jpg");
}
#[test]
fn user_tags_upgrade_ai_tags_and_survive_tag_maintenance() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'cat', 'ai', 'wd', 0.9, datetime('now'))",
[id],
)
.unwrap();
let upgraded = add_user_tag(&conn, id, "cat").unwrap();
assert_eq!(upgraded.source, "user");
assert_eq!(upgraded.ai_model, None);
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
let tag = add_user_tag(&conn, id, "kitty").unwrap();
remove_tag(&conn, tag.id).unwrap();
assert_eq!(get_image_tags(&conn, id).unwrap().len(), 1);
}
#[test]
fn rename_tag_merges_into_existing_tag() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let with_both = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let with_old = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
add_user_tag(&conn, with_both, "cat").unwrap();
add_user_tag(&conn, with_both, "kitty").unwrap();
add_user_tag(&conn, with_old, "kitty").unwrap();
rename_tag(&conn, "kitty", "cat").unwrap();
let both_tags = get_image_tags(&conn, with_both).unwrap();
assert_eq!(both_tags.len(), 1);
assert_eq!(both_tags[0].tag, "cat");
let old_tags = get_image_tags(&conn, with_old).unwrap();
assert_eq!(old_tags.len(), 1);
assert_eq!(old_tags[0].tag, "cat");
}
#[test]
fn delete_tag_removes_it_everywhere_and_reports_count() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
add_user_tag(&conn, first, "cat").unwrap();
add_user_tag(&conn, second, "cat").unwrap();
add_user_tag(&conn, second, "dog").unwrap();
assert_eq!(delete_tag(&conn, "cat").unwrap(), 2);
assert_eq!(get_image_tags(&conn, first).unwrap().len(), 0);
assert_eq!(get_image_tags(&conn, second).unwrap().len(), 1);
}
#[test]
fn album_crud_and_membership() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let first = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_id, "C:/a/b.jpg")).unwrap();
let album = create_album(&conn, "Holiday").unwrap();
assert_eq!(album.name, "Holiday");
assert_eq!(album.image_count, 0);
// Adding is idempotent.
assert_eq!(
add_images_to_album(&conn, album.id, &[first, second]).unwrap(),
2
);
assert_eq!(add_images_to_album(&conn, album.id, &[first]).unwrap(), 0);
assert_eq!(count_album_images(&conn, album.id).unwrap(), 2);
remove_images_from_album(&conn, album.id, &[first]).unwrap();
assert_eq!(count_album_images(&conn, album.id).unwrap(), 1);
rename_album(&conn, album.id, "Trip").unwrap();
assert_eq!(get_album(&conn, album.id).unwrap().name, "Trip");
delete_album(&conn, album.id).unwrap();
assert!(list_albums(&conn).unwrap().is_empty());
// Membership rows cascade away with the album.
let orphans: i64 = conn
.query_row("SELECT COUNT(*) FROM album_images", [], |row| row.get(0))
.unwrap();
assert_eq!(orphans, 0);
}
#[test]
fn backfill_enqueues_only_unqueued_unready_images() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let pending = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
let mut ready = test_image(folder_id, "C:/a/b.jpg");
ready.embedding_status = "ready".into();
upsert_image(&conn, &ready).unwrap();
let queued = upsert_image(&conn, &test_image(folder_id, "C:/a/c.jpg")).unwrap();
enqueue_embedding_job(&conn, queued).unwrap();
assert_eq!(backfill_embedding_jobs(&conn).unwrap(), 1);
let has_job: i64 = conn
.query_row(
"SELECT COUNT(*) FROM embedding_jobs WHERE image_id = ?1",
[pending],
|row| row.get(0),
)
.unwrap();
assert_eq!(has_job, 1);
}
#[test]
fn retry_failed_embeddings_skips_thumbnailless_videos() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let mut failed_image = test_image(folder_id, "C:/a/a.jpg");
failed_image.embedding_status = "failed".into();
let failed_image_id = upsert_image(&conn, &failed_image).unwrap();
let mut failed_video = test_image(folder_id, "C:/a/clip.mp4");
failed_video.media_kind = "video".into();
failed_video.embedding_status = "failed".into();
failed_video.thumbnail_path = None;
upsert_image(&conn, &failed_video).unwrap();
assert_eq!(retry_failed_embedding_jobs(&conn, folder_id).unwrap(), 1);
let requeued = get_image_by_id(&conn, failed_image_id).unwrap();
assert_eq!(requeued.embedding_status, "pending");
}
#[test]
fn repair_embedding_consistency_requeues_ready_images_without_vectors() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let mut ready = test_image(folder_id, "C:/a/a.jpg");
ready.embedding_status = "ready".into();
let ready_id = upsert_image(&conn, &ready).unwrap();
let (orphaned, requeued) = repair_embedding_consistency(&conn).unwrap();
assert_eq!(orphaned, 0);
assert_eq!(requeued, 1);
let repaired = get_image_by_id(&conn, ready_id).unwrap();
assert_eq!(repaired.embedding_status, "pending");
}
#[test]
fn search_images_by_tag_is_case_insensitive_and_paginates() {
let conn = test_conn();
let folder_a = insert_folder(&conn, "C:/a", "a").unwrap();
let folder_b = insert_folder(&conn, "C:/b", "b").unwrap();
let first = upsert_image(&conn, &test_image(folder_a, "C:/a/a.jpg")).unwrap();
let second = upsert_image(&conn, &test_image(folder_a, "C:/a/b.jpg")).unwrap();
let third = upsert_image(&conn, &test_image(folder_b, "C:/b/c.jpg")).unwrap();
add_user_tag(&conn, first, "Cat").unwrap();
add_user_tag(&conn, second, "cat").unwrap();
add_user_tag(&conn, third, "cat").unwrap();
add_user_tag(&conn, third, "dog").unwrap();
// Query is trimmed and matched case-insensitively against stored tags.
let (all, total) =
search_images_by_tag(&conn, " CAT ", None, None, false, 0, None, 10, 0).unwrap();
assert_eq!(total, 3);
assert_eq!(all.len(), 3);
let (scoped, scoped_total) =
search_images_by_tag(&conn, "cat", Some(folder_a), None, false, 0, None, 10, 0)
.unwrap();
assert_eq!(scoped_total, 2);
assert_eq!(scoped.len(), 2);
// Pagination: total stays the full count while the page shrinks.
let (page, page_total) =
search_images_by_tag(&conn, "cat", None, None, false, 0, None, 2, 2).unwrap();
assert_eq!(page_total, 3);
assert_eq!(page.len(), 1);
// Blank queries return nothing instead of matching everything.
let (empty, empty_total) =
search_images_by_tag(&conn, " ", None, None, false, 0, None, 10, 0).unwrap();
assert!(empty.is_empty());
assert_eq!(empty_total, 0);
}
#[test]
fn update_ai_tags_replaces_ai_state_without_downgrading_user_tags() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
add_user_tag(&conn, id, "cat").unwrap();
conn.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, 'stale', 'ai', 'wd', 0.5, datetime('now'))",
[id],
)
.unwrap();
enqueue_tagging_job(&conn, id).unwrap();
update_ai_tags(
&conn,
id,
&[("cat".into(), 0.9), ("outdoors".into(), 0.7)],
"general",
"wd",
)
.unwrap();
let tags = get_image_tags(&conn, id).unwrap();
assert_eq!(tags.len(), 2, "stale AI tag should be gone");
let cat = tags.iter().find(|tag| tag.tag == "cat").unwrap();
assert_eq!(cat.source, "user", "user tag must not be downgraded to ai");
let outdoors = tags.iter().find(|tag| tag.tag == "outdoors").unwrap();
assert_eq!(outdoors.source, "ai");
assert_eq!(outdoors.confidence, Some(0.7));
let record = get_image_by_id(&conn, id).unwrap();
assert_eq!(record.ai_rating.as_deref(), Some("general"));
assert_eq!(record.ai_tagger_model.as_deref(), Some("wd"));
assert_eq!(record.ai_tagger_error, None);
let pending_jobs: i64 = conn
.query_row(
"SELECT COUNT(*) FROM tagging_jobs WHERE image_id = ?1",
[id],
|row| row.get(0),
)
.unwrap();
assert_eq!(pending_jobs, 0, "completed job should be dequeued");
}
#[test]
fn delete_folder_cascades_images_tags_and_vectors() {
let conn = test_conn();
let folder_id = insert_folder(&conn, "C:/a", "a").unwrap();
let id = upsert_image(&conn, &test_image(folder_id, "C:/a/a.jpg")).unwrap();
add_user_tag(&conn, id, "cat").unwrap();
vector::upsert_embedding(&conn, id, &vec![0.5f32; vector::CLIP_VECTOR_DIM]).unwrap();
delete_folder(&conn, folder_id).unwrap();
assert!(get_folders(&conn).unwrap().is_empty());
let remaining_images: i64 = conn
.query_row("SELECT COUNT(*) FROM images", [], |row| row.get(0))
.unwrap();
assert_eq!(remaining_images, 0);
let remaining_tags: i64 = conn
.query_row("SELECT COUNT(*) FROM image_tags", [], |row| row.get(0))
.unwrap();
assert_eq!(remaining_tags, 0);
assert!(!vector::has_image_vector(&conn, id).unwrap());
}
}
+240
View File
@@ -0,0 +1,240 @@
//! Resilient file downloads via the system `curl` binary.
//!
//! Used for all large model/runtime downloads (tagger models, ONNX Runtime
//! DLLs, caption model). ureq's read timeout does not fire on a stalled large
//! transfer on Windows (schannel doesn't honor the socket read timeout), so a
//! stall there hangs forever. curl detects an inactivity stall
//! (`--speed-time`), resumes from the partial file (`-C -`), and retries
//! internally — the same behavior a browser gets.
use anyhow::Result;
use std::io::Read;
use std::path::Path;
use std::process::Command;
// Suppress the console window when spawning curl from the GUI app.
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
#[cfg(target_os = "windows")]
const CREATE_NO_WINDOW: u32 = 0x08000000;
/// Discard target for curl output we only need the headers of.
#[cfg(target_os = "windows")]
const NULL_DEVICE: &str = "NUL";
#[cfg(not(target_os = "windows"))]
const NULL_DEVICE: &str = "/dev/null";
/// Build a `curl` command with platform quirks applied. Windows resolves the
/// bare name to `curl.exe` (bundled since Windows 10 1803) and needs the
/// no-window flag; macOS ships curl; Linux is expected to have it installed.
fn curl_command() -> Command {
#[allow(unused_mut)]
let mut command = Command::new("curl");
#[cfg(target_os = "windows")]
command.creation_flags(CREATE_NO_WINDOW);
command
}
// Give up only after this many *consecutive* curl runs that download nothing;
// a run that makes any progress resets the counter, so a large file completes
// across however many resumes it takes. Kept low so a hard stall (e.g. a
// broken VM NIC) fails in a couple of minutes — surfacing a retryable error —
// rather than locking the UI on "preparing" for many minutes.
const MAX_STALL_RETRIES: usize = 3;
/// Resiliently download `url` to `destination` using the system `curl`.
///
/// We monitor the `.part` file's size for the progress bar and wrap curl in an
/// outer progress-aware retry as a backstop. The partial file survives an app
/// restart, so a later retry continues from disk.
pub fn download_file_resilient(
url: &str,
destination: &Path,
mut on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
let part = match destination.extension() {
Some(ext) => destination.with_extension(format!("{}.part", ext.to_string_lossy())),
None => destination.with_extension("part"),
};
let name = destination
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| url.to_string());
log::info!("{name}: resolving download size");
let total = remote_content_length(url);
// Reconcile any existing `.part` against the real size: exactly complete →
// finish; oversized (stale/corrupt) → discard so curl restarts cleanly
// (otherwise `curl -C -` would 416 forever).
if let Some(total) = total {
let size = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if size == total {
std::fs::rename(&part, destination)?;
return Ok(());
}
if size > total {
let _ = std::fs::remove_file(&part);
}
}
log::info!(
"{name}: downloading via curl ({} bytes)",
total
.map(|t| t.to_string())
.unwrap_or_else(|| "unknown size".into())
);
let mut stalls = 0usize;
loop {
let before = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
match run_curl_download(url, &part, total, &mut on_progress) {
Ok(()) => break,
Err(error) => {
let after = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if after > before {
log::warn!("{name}: curl interrupted at {after} bytes, resuming: {error}");
stalls = 0;
} else {
stalls += 1;
log::warn!(
"{name}: curl made no progress ({stalls}/{MAX_STALL_RETRIES}): {error}"
);
if stalls >= MAX_STALL_RETRIES {
// Discard the partial so a future attempt restarts clean
// rather than getting stuck re-resuming a bad file.
let _ = std::fs::remove_file(&part);
return Err(error);
}
}
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
}
if let Some(total) = total {
let got = std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
if got < total {
anyhow::bail!("{name}: incomplete after curl ({got}/{total} bytes)");
}
}
std::fs::rename(&part, destination)?;
Ok(())
}
/// Size probe via `curl -r 0-0` (a 1-byte Range request), parsing the total
/// from the `Content-Range: bytes 0-0/<total>` header. Uses curl rather than
/// ureq so no part of the download path depends on ureq (which hangs on this
/// VM's TLS stack). Returns None if the server doesn't report a size.
fn remote_content_length(url: &str) -> Option<u64> {
let mut command = curl_command();
command.args([
"-sL",
"-r",
"0-0",
"-D",
"-",
"-o",
NULL_DEVICE,
"--connect-timeout",
"30",
"--max-time",
"30",
"--",
url,
]);
let output = command.output().ok()?;
let headers = String::from_utf8_lossy(&output.stdout);
for line in headers.lines() {
if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-range:") {
if let Some(total) = rest.rsplit('/').next().map(str::trim) {
if let Ok(n) = total.parse::<u64>() {
return Some(n);
}
}
}
}
None
}
/// Run one `curl` download to `dest`, resuming from any partial file, while
/// reporting progress from the growing file size. Returns an error (leaving the
/// partial in place) if curl exits non-zero.
fn run_curl_download(
url: &str,
dest: &Path,
total: Option<u64>,
on_progress: &mut impl FnMut(u64, Option<u64>),
) -> Result<()> {
let mut command = curl_command();
command
.arg("-fSL") // fail on HTTP errors, follow redirects, show errors
.args(["-C", "-"]) // resume from the existing output file
.args(["--retry", "3", "--retry-delay", "1", "--retry-connrefused"])
.args(["--connect-timeout", "30"])
// Abort (then --retry resumes) if under 1 KB/s for 30s — a real
// inactivity timeout, which is what ureq couldn't deliver here.
.args(["--speed-limit", "1024", "--speed-time", "30"])
.arg("-s") // no progress meter (we watch the file instead)
.arg("-o")
.arg(dest)
.arg("--")
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
let mut child = command
.spawn()
.map_err(|e| anyhow::anyhow!("failed to launch curl (required for downloads): {e}"))?;
loop {
if let Some(status) = child.try_wait()? {
if status.success() {
return Ok(());
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
anyhow::bail!(
"curl exited with {}: {}",
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".into()),
stderr.trim()
);
}
let downloaded = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
on_progress(downloaded, total);
std::thread::sleep(std::time::Duration::from_millis(300));
}
}
/// Download a NuGet package (a zip) resiliently, then extract the single file
/// at `archive_path` into `destination`.
pub fn download_nuget_file(
source_url: &str,
archive_path: &str,
destination: &Path,
on_progress: impl FnMut(u64, Option<u64>),
) -> Result<()> {
let package = destination.with_extension("nupkg");
download_file_resilient(source_url, &package, on_progress)?;
log::info!("extracting {archive_path} from package");
let file = std::fs::File::open(&package)?;
let mut archive = zip::ZipArchive::new(file)?;
let mut dll = archive.by_name(archive_path)?;
let temp_destination = destination.with_extension("tmp");
{
let mut out = std::fs::File::create(&temp_destination)?;
std::io::copy(&mut dll, &mut out)?;
}
std::fs::rename(&temp_destination, destination)?;
let _ = std::fs::remove_file(&package);
log::info!("extracted {archive_path}");
Ok(())
}
+135 -16
View File
@@ -3,13 +3,13 @@ use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, Inde
use crate::embedder::{embedding_source_path, ClipImageEmbedder}; use crate::embedder::{embedding_source_path, ClipImageEmbedder};
use crate::media::{probe_video_metadata, MediaTools}; use crate::media::{probe_video_metadata, MediaTools};
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile}; use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
use crate::tagger::{self, WdTagger}; use crate::tagger::{self, Tagger};
use crate::thumbnail; use crate::thumbnail;
use crate::vector; use crate::vector;
use anyhow::Result; use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
@@ -41,6 +41,14 @@ struct PausedWorkerFolders {
tagging: HashSet<i64>, tagging: HashSet<i64>,
} }
#[derive(Default, Deserialize, Serialize)]
pub struct PersistedPausedWorkerFolders {
pub thumbnail: Vec<i64>,
pub metadata: Vec<i64>,
pub embedding: Vec<i64>,
pub tagging: Vec<i64>,
}
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub struct FolderWorkerPausedState { pub struct FolderWorkerPausedState {
pub thumbnail: bool, pub thumbnail: bool,
@@ -50,6 +58,41 @@ pub struct FolderWorkerPausedState {
pub tagging: bool, pub tagging: bool,
} }
pub fn replace_worker_paused_states(states: PersistedPausedWorkerFolders) {
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
.lock()
{
paused_folders.thumbnail = states.thumbnail.into_iter().collect();
paused_folders.metadata = states.metadata.into_iter().collect();
paused_folders.embedding = states.embedding.into_iter().collect();
paused_folders.caption = HashSet::new();
paused_folders.tagging = states.tagging.into_iter().collect();
}
}
pub fn snapshot_worker_paused_states() -> PersistedPausedWorkerFolders {
let Ok(paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
.lock()
else {
return PersistedPausedWorkerFolders::default();
};
let sorted = |set: &HashSet<i64>| {
let mut ids = set.iter().copied().collect::<Vec<_>>();
ids.sort_unstable();
ids
};
PersistedPausedWorkerFolders {
thumbnail: sorted(&paused_folders.thumbnail),
metadata: sorted(&paused_folders.metadata),
embedding: sorted(&paused_folders.embedding),
tagging: sorted(&paused_folders.tagging),
}
}
pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) { pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) {
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default())) .get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
@@ -330,7 +373,7 @@ pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) { pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) {
std::thread::spawn(move || { std::thread::spawn(move || {
let mut tagger_instance: Option<WdTagger> = None; let mut tagger_instance: Option<Box<dyn Tagger>> = None;
log::info!("Tagging worker started."); log::info!("Tagging worker started.");
loop { loop {
// If the acceleration setting changed, drop the cached session so // If the acceleration setting changed, drop the cached session so
@@ -1236,7 +1279,7 @@ fn process_tagging_batch(
app: &AppHandle, app: &AppHandle,
pool: &DbPool, pool: &DbPool,
app_data_dir: &Path, app_data_dir: &Path,
tagger_instance: &mut Option<WdTagger>, tagger_instance: &mut Option<Box<dyn Tagger>>,
) -> Result<bool> { ) -> Result<bool> {
if !tagger::tagger_model_status(app_data_dir).ready { if !tagger::tagger_model_status(app_data_dir).ready {
return Ok(false); return Ok(false);
@@ -1248,20 +1291,23 @@ fn process_tagging_batch(
// Exclude actively-indexing folders for the same reason as the other // Exclude actively-indexing folders for the same reason as the other
// workers: don't compete with a running scan. // workers: don't compete with a running scan.
let batch_started_at = Instant::now();
let mut excluded_folders = paused_folder_ids("tagging"); let mut excluded_folders = paused_folder_ids("tagging");
excluded_folders.extend(active_indexing_folders()); excluded_folders.extend(active_indexing_folders());
let batch_size = crate::tagger::tagger_batch_size(app_data_dir); let batch_size = crate::tagger::tagger_batch_size(app_data_dir);
let claim_started_at = Instant::now();
let jobs = with_db_write_lock(|| { let jobs = with_db_write_lock(|| {
let mut conn = pool.get()?; let mut conn = pool.get()?;
db::claim_tagging_jobs(&mut conn, &excluded_folders, batch_size) db::claim_tagging_jobs(&mut conn, &excluded_folders, batch_size)
})?; })?;
let claim_elapsed = claim_started_at.elapsed();
if jobs.is_empty() { if jobs.is_empty() {
return Ok(false); return Ok(false);
} }
if tagger_instance.is_none() { if tagger_instance.is_none() {
match WdTagger::new(app_data_dir) { match tagger::create_active_tagger(app_data_dir) {
Ok(model) => *tagger_instance = Some(model), Ok(model) => *tagger_instance = Some(model),
Err(error) => { Err(error) => {
with_db_write_lock(|| { with_db_write_lock(|| {
@@ -1288,21 +1334,45 @@ fn process_tagging_batch(
.as_mut() .as_mut()
.expect("tagger should be initialized before tagging batch processing"); .expect("tagger should be initialized before tagging batch processing");
let tag_results = jobs // Resolve each job's source image (AVIF can't be decoded directly, so it
// falls back to its thumbnail), then tag the batch in small chunks (below).
let source_paths: Vec<PathBuf> = jobs
.iter() .iter()
.map(|job| { .map(|job| {
let source_path = if is_avif_path(Path::new(&job.path)) { if is_avif_path(Path::new(&job.path)) {
job.thumbnail_path.as_deref().unwrap_or(&job.path) PathBuf::from(job.thumbnail_path.as_deref().unwrap_or(&job.path))
} else { } else {
&job.path PathBuf::from(&job.path)
}; }
(
job.clone(),
tagger_ref.run(Path::new(source_path), tagger::DEFAULT_MAX_TAGS),
)
}) })
.collect::<Vec<_>>(); .collect();
// Tag in small micro-batches instead of one wide forward pass. On a shared
// GPU every DirectML dispatch blocks the WebView2 compositor for its whole
// duration, so a 16-wide batch freezes the UI for seconds; small chunks keep
// each GPU lock short (and bound peak decode memory) while a brief yield
// between them lets the UI and other workers grab the GPU/CPU. The model is
// compute-bound here, so this costs almost no throughput.
let infer_started_at = Instant::now();
let mut outputs = Vec::with_capacity(source_paths.len());
let mut chunks = source_paths.chunks(tagger::TAGGER_INFER_CHUNK).peekable();
while let Some(chunk) = chunks.next() {
outputs.extend(tagger_ref.run_batch(chunk, tagger::DEFAULT_MAX_TAGS));
if chunks.peek().is_some() {
std::thread::sleep(std::time::Duration::from_millis(
tagger::TAGGER_INFER_YIELD_MS,
));
}
}
let infer_elapsed = infer_started_at.elapsed();
// Attribute the tags to the model that actually produced them, not a
// hardcoded one (WD vs JoyTag are both possible).
let tagger_model_name = tagger_ref.model_name();
let tag_results = jobs.iter().cloned().zip(outputs).collect::<Vec<_>>();
let write_started_at = Instant::now();
let updated_images = with_db_write_lock(|| { let updated_images = with_db_write_lock(|| {
let mut conn = pool.get()?; let mut conn = pool.get()?;
let tx = conn.transaction()?; let tx = conn.transaction()?;
@@ -1332,7 +1402,7 @@ fn process_tagging_batch(
job.image_id, job.image_id,
&tag_pairs, &tag_pairs,
&output.rating, &output.rating,
tagger::WD_TAGGER_MODEL_NAME, tagger_model_name,
)?; )?;
} }
Err(error) => { Err(error) => {
@@ -1354,6 +1424,7 @@ fn process_tagging_batch(
db::requeue_tagging_jobs(&conn, &image_ids) db::requeue_tagging_jobs(&conn, &image_ids)
}); });
})?; })?;
let write_elapsed = write_started_at.elapsed();
if !updated_images.is_empty() { if !updated_images.is_empty() {
let folder_ids = updated_images let folder_ids = updated_images
@@ -1369,6 +1440,15 @@ fn process_tagging_batch(
emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true); emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::<Vec<_>>(), true);
} }
log::info!(
"Tagging batch timing: {} items, claim {:?}, tag {:?}, write {:?}, total {:?}",
jobs.len(),
claim_elapsed,
infer_elapsed,
write_elapsed,
batch_started_at.elapsed()
);
Ok(true) Ok(true)
} }
@@ -1915,3 +1995,42 @@ fn process_watcher_rename(
Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"), Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"),
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_media_matches_known_extensions_case_insensitively() {
for path in ["a.jpg", "b.JPEG", "c.PNG", "d.avif", "e.mp4", "f.WEBM"] {
assert!(
is_supported_media(Path::new(path)),
"{path} should be supported"
);
}
for path in ["notes.txt", "archive.zip", "no_extension", "clip.mkv"] {
assert!(
!is_supported_media(Path::new(path)),
"{path} should be skipped"
);
}
}
#[test]
fn media_kind_splits_video_from_image_extensions() {
for ext in ["mp4", "MOV", "m4v", "webm"] {
assert_eq!(media_kind_for_ext(ext), "video");
}
for ext in ["jpg", "PNG", "webp", "avif"] {
assert_eq!(media_kind_for_ext(ext), "image");
}
}
#[test]
fn mime_types_map_per_extension() {
assert_eq!(mime_for_ext("JPG"), "image/jpeg");
assert_eq!(mime_for_ext("png"), "image/png");
assert_eq!(mime_for_ext("mov"), "video/quicktime");
assert_eq!(mime_for_ext("m4v"), "video/mp4");
}
}
+11 -1
View File
@@ -1,11 +1,14 @@
mod ai_tag_filter;
mod captioner; mod captioner;
mod color; mod color;
mod commands; mod commands;
mod db; mod db;
mod download;
mod embedder; mod embedder;
mod hnsw_index; mod hnsw_index;
mod indexer; mod indexer;
mod media; mod media;
mod onnx_runtime;
mod storage; mod storage;
mod tagger; mod tagger;
mod thumbnail; mod thumbnail;
@@ -118,6 +121,7 @@ pub fn run() {
let thumb_dir = app_dir.join("thumbnails"); let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir"); std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
commands::restore_persisted_worker_pauses(&app_dir);
// The asset protocol scope is no longer a blanket "**": thumbnails // The asset protocol scope is no longer a blanket "**": thumbnails
// are allowed statically in tauri.conf.json, and each indexed // are allowed statically in tauri.conf.json, and each indexed
@@ -194,14 +198,19 @@ pub fn run() {
commands::suggest_image_tags, commands::suggest_image_tags,
commands::set_worker_paused, commands::set_worker_paused,
commands::get_worker_states, commands::get_worker_states,
commands::get_tag_cloud, commands::get_worker_pauses_persist,
commands::set_worker_pauses_persist,
commands::get_visual_clusters,
commands::get_explore_tags, commands::get_explore_tags,
commands::get_related_tags,
commands::get_images_by_ids, commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_failed_tagging_images, commands::get_failed_tagging_images,
commands::get_tagger_model_status, commands::get_tagger_model_status,
commands::get_tagger_acceleration, commands::get_tagger_acceleration,
commands::set_tagger_acceleration, commands::set_tagger_acceleration,
commands::get_tagger_model,
commands::set_tagger_model,
commands::probe_tagger_runtime, commands::probe_tagger_runtime,
commands::get_tagger_threshold, commands::get_tagger_threshold,
commands::set_tagger_threshold, commands::set_tagger_threshold,
@@ -211,6 +220,7 @@ pub fn run() {
commands::delete_tagger_model, commands::delete_tagger_model,
commands::queue_tagging_jobs, commands::queue_tagging_jobs,
commands::clear_tagging_jobs, commands::clear_tagging_jobs,
commands::reset_ai_tags,
commands::get_image_tags, commands::get_image_tags,
commands::add_user_tag, commands::add_user_tag,
commands::remove_tag, commands::remove_tag,
+137
View File
@@ -0,0 +1,137 @@
//! Shared ONNX Runtime provisioning: downloading the runtime + DirectML DLLs
//! and initializing `ort` from them.
//!
//! Both ONNX consumers go through here — the tagger (live) and the Florence-2
//! captioner (backend intact, UI disabled). The DLLs are Windows/DirectML
//! specific; a future cross-platform build needs a per-OS runtime strategy.
use crate::download;
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const ONNX_RUNTIME_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.ML.OnnxRuntime.DirectML/1.24.2";
const DIRECTML_NUGET_URL: &str =
"https://www.nuget.org/api/v2/package/Microsoft.AI.DirectML/1.15.4";
pub const ONNX_RUNTIME_DLL_FILE: &str = "onnxruntime/onnxruntime.dll";
pub const ONNX_RUNTIME_PROVIDERS_DLL_FILE: &str = "onnxruntime/onnxruntime_providers_shared.dll";
pub const DIRECTML_DLL_FILE: &str = "onnxruntime/DirectML.dll";
/// The shared runtime DLLs, as paths relative to [`runtime_dir`].
pub const RUNTIME_DLLS: &[&str] = &[
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
DIRECTML_DLL_FILE,
];
/// `(destination_file, nuget_package_url, path_inside_package)` for each DLL.
pub const ONNX_RUNTIME_FILES: &[(&str, &str, &str)] = &[
(
ONNX_RUNTIME_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime.dll",
),
(
ONNX_RUNTIME_PROVIDERS_DLL_FILE,
ONNX_RUNTIME_NUGET_URL,
"runtimes/win-x64/native/onnxruntime_providers_shared.dll",
),
(
DIRECTML_DLL_FILE,
DIRECTML_NUGET_URL,
"bin/x64-win/DirectML.dll",
),
];
// Mutex<bool> rather than OnceLock<Result>: a failed attempt (DLL not yet
// downloaded) must NOT be cached, or a later successful download could never
// recover within the same app session.
static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// Directory the shared runtime DLLs are provisioned into.
///
/// Historically the DLLs were downloaded as part of the Florence-2 caption
/// model, so they live inside that model's directory
/// (`models/florence-2-base-ft/onnxruntime/`). The location is kept even
/// though the tagger is now the main consumer, so existing installs don't
/// have to re-download the runtime.
pub fn runtime_dir(app_data_dir: &Path) -> PathBuf {
crate::captioner::model_dir(app_data_dir)
}
/// Initialize `ort` from the already-downloaded runtime DLL in `local_dir`.
/// Fails if the DLL is missing — use [`provision_onnx_runtime_with_progress`]
/// to download it first on a clean install.
pub fn ensure_onnx_runtime(local_dir: &Path) -> Result<()> {
let mut initialized = ORT_RUNTIME_INIT
.lock()
.map_err(|_| anyhow::anyhow!("ONNX runtime init lock poisoned"))?;
if *initialized {
return Ok(());
}
let dll_path = local_dir.join(ONNX_RUNTIME_DLL_FILE);
if !dll_path.exists() {
anyhow::bail!("ONNX Runtime DLL is missing: {}", dll_path.display());
}
ort::environment::init_from(&dll_path)
.map_err(|error| anyhow::anyhow!(error.to_string()))?
.with_name("phokus-florence")
.commit();
*initialized = true;
Ok(())
}
/// Download any ONNX Runtime DLLs missing from `local_dir`, reporting per-file
/// byte progress as `(short_label, downloaded_bytes, total_bytes)`.
/// `total_bytes` is `None` when the server omits Content-Length. Unlike
/// `ensure_onnx_runtime` (init only), this actually provisions the files —
/// callers that can run on a clean install must call this first. The callback
/// fires per chunk; callers should throttle.
pub fn provision_onnx_runtime_with_progress(
local_dir: &Path,
mut on_progress: impl FnMut(&str, u64, Option<u64>),
) -> Result<()> {
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
if destination.exists() {
continue;
}
// Strip the "onnxruntime/" prefix for a clean label.
let label = destination_file
.rsplit('/')
.next()
.unwrap_or(destination_file);
download::download_nuget_file(
source_url,
archive_path,
&destination,
|downloaded, total| on_progress(label, downloaded, total),
)?;
}
Ok(())
}
/// Number of ONNX Runtime DLLs still missing from `local_dir` (for progress
/// step counts before downloading).
pub fn missing_onnx_runtime_count(local_dir: &Path) -> usize {
ONNX_RUNTIME_FILES
.iter()
.filter(|(destination_file, _, _)| !local_dir.join(destination_file).exists())
.count()
}
/// Download all missing runtime DLLs without progress reporting.
pub fn download_onnx_runtime_files(local_dir: &Path) -> Result<()> {
if !cfg!(target_os = "windows") {
anyhow::bail!("ONNX Runtime DLL download is currently configured for Windows builds");
}
for (destination_file, source_url, archive_path) in ONNX_RUNTIME_FILES {
let destination = local_dir.join(destination_file);
download::download_nuget_file(source_url, archive_path, &destination, |_, _| {})?;
}
Ok(())
}
+50
View File
@@ -122,3 +122,53 @@ fn fallback_profile_for_path(path: &Path) -> StorageProfile {
StorageProfile::Balanced StorageProfile::Balanced
} }
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn thumbnail_workers_scale_with_parallelism_within_clamps() {
assert_eq!(StorageProfile::Fast.thumbnail_workers(3), 2);
assert_eq!(StorageProfile::Fast.thumbnail_workers(12), 4);
assert_eq!(StorageProfile::Fast.thumbnail_workers(64), 4);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(4), 2);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(12), 3);
assert_eq!(StorageProfile::Balanced.thumbnail_workers(64), 3);
assert_eq!(StorageProfile::Conservative.thumbnail_workers(64), 1);
}
#[test]
fn adaptive_profile_tracks_scan_speed() {
let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Balanced);
assert_eq!(adaptive.profile(), StorageProfile::Balanced);
// 1 ms/item → fast storage.
adaptive.observe_scan_batch(10, Duration::from_millis(10));
assert_eq!(adaptive.profile(), StorageProfile::Fast);
// A very slow batch drags the EMA over the conservative threshold.
adaptive.observe_scan_batch(1, Duration::from_millis(100));
assert_eq!(adaptive.profile(), StorageProfile::Conservative);
}
#[test]
fn adaptive_profile_ignores_empty_batches() {
let mut adaptive = RuntimeAdaptiveProfile::new(StorageProfile::Fast);
adaptive.observe_scan_batch(0, Duration::from_secs(10));
assert_eq!(adaptive.profile(), StorageProfile::Fast);
}
#[test]
fn fallback_profile_treats_unc_paths_as_conservative() {
assert_eq!(
fallback_profile_for_path(Path::new("\\\\server\\share\\photos")),
StorageProfile::Conservative
);
assert_eq!(
fallback_profile_for_path(Path::new("C:\\photos")),
StorageProfile::Balanced
);
}
}
+647 -105
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -383,6 +383,26 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn fit_dimensions_preserves_aspect_ratio_within_max() {
// Already small enough: unchanged.
assert_eq!(fit_dimensions(100, 50, THUMB_SIZE), (100, 50));
// Landscape and portrait scale to the max on their long edge.
assert_eq!(fit_dimensions(6400, 3200, THUMB_SIZE), (320, 160));
assert_eq!(fit_dimensions(3200, 6400, THUMB_SIZE), (160, 320));
// Extreme ratios never collapse to zero.
assert_eq!(fit_dimensions(1, 100_000, 320), (1, 320));
assert_eq!(fit_dimensions(100_000, 1, 320), (320, 1));
}
#[test]
fn is_jpeg_checks_extension_only() {
assert!(is_jpeg(Path::new("photo.jpg")));
assert!(is_jpeg(Path::new("photo.JPEG")));
assert!(!is_jpeg(Path::new("photo.png")));
assert!(!is_jpeg(Path::new("photo")));
}
#[test] #[test]
fn scale_numerator_picks_smallest_sufficient() { fn scale_numerator_picks_smallest_sufficient() {
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1); assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
+116
View File
@@ -263,6 +263,44 @@ pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`. /// Each entry is `(image_id, normalized_f32_embedding)`.
/// Returns `(count, hash)` over the stored embedding image IDs for the scope in a
/// single ordered pass, without loading any embedding blobs. The hash covers the
/// exact set of IDs, so it is membership-sensitive: adding, removing, or moving an
/// image between folders changes it even when the count happens to stay the same.
/// Used (together with the embedding revision, which catches an image being
/// re-embedded in place) as the cheap visual-cluster cache key so a cache hit doesn't
/// have to read and unpack hundreds of MB of embeddings just to validate freshness.
pub fn embedding_ids_signature(conn: &Connection, folder_id: Option<i64>) -> Result<(i64, u64)> {
use xxhash_rust::xxh3::Xxh3;
let mut hasher = Xxh3::new();
let mut count: i64 = 0;
let mut hash_row = |id: i64| {
hasher.update(&id.to_le_bytes());
count += 1;
};
match folder_id {
Some(fid) => {
let mut stmt = conn.prepare(
"SELECT image_id FROM image_vec
WHERE image_id IN (SELECT id FROM images WHERE folder_id = ?1)
ORDER BY image_id",
)?;
let mut rows = stmt.query([fid])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
None => {
let mut stmt = conn.prepare("SELECT image_id FROM image_vec ORDER BY image_id")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
hash_row(row.get(0)?);
}
}
}
Ok((count, hasher.digest()))
}
pub fn get_all_image_embeddings_with_ids( pub fn get_all_image_embeddings_with_ids(
conn: &Connection, conn: &Connection,
folder_id: Option<i64>, folder_id: Option<i64>,
@@ -524,3 +562,81 @@ fn pack_f32(values: &[f32]) -> Vec<u8> {
} }
out out
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::db::test_support::{test_conn, test_image};
#[test]
fn pack_unpack_roundtrip() {
let values = vec![0.0f32, 1.5, -2.25, f32::MIN_POSITIVE, 1e10];
assert_eq!(unpack_f32(&pack_f32(&values)), values);
assert!(unpack_f32(&pack_f32(&[])).is_empty());
}
#[test]
fn upsert_embedding_rejects_wrong_dimension() {
let conn = test_conn();
let error = upsert_embedding(&conn, 1, &[0.5f32; 3]).unwrap_err();
assert!(error.to_string().contains("dimension"));
}
#[test]
fn upsert_and_delete_embedding_roundtrip() {
let conn = test_conn();
let embedding = vec![0.25f32; CLIP_VECTOR_DIM];
upsert_embedding(&conn, 42, &embedding).unwrap();
assert!(has_image_vector(&conn, 42).unwrap());
// Upsert replaces rather than duplicates.
upsert_embedding(&conn, 42, &embedding).unwrap();
let rows: i64 = conn
.query_row(
"SELECT COUNT(*) FROM image_vec WHERE image_id = 42",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(rows, 1);
delete_embedding(&conn, 42).unwrap();
assert!(!has_image_vector(&conn, 42).unwrap());
}
#[test]
fn find_similar_image_ids_ranks_by_cosine_distance() {
let conn = test_conn();
let folder_id = crate::db::insert_folder(&conn, "C:/a", "a").unwrap();
let base_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/base.jpg")).unwrap();
let close_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/close.jpg")).unwrap();
let far_id =
crate::db::upsert_image(&conn, &test_image(folder_id, "C:/a/far.jpg")).unwrap();
let mut base = vec![0.0f32; CLIP_VECTOR_DIM];
base[0] = 1.0;
let mut close = vec![0.0f32; CLIP_VECTOR_DIM];
close[0] = 1.0;
close[1] = 0.2;
let mut far = vec![0.0f32; CLIP_VECTOR_DIM];
far[1] = 1.0;
upsert_embedding(&conn, base_id, &base).unwrap();
upsert_embedding(&conn, close_id, &close).unwrap();
upsert_embedding(&conn, far_id, &far).unwrap();
// Global KNN path: nearest first, query image excluded.
let global = find_similar_image_ids(&conn, base_id, 2, None).unwrap();
assert_eq!(global, vec![close_id, far_id]);
// Folder-scoped brute-force path returns the same ranking.
let scoped = find_similar_image_ids(&conn, base_id, 2, Some(folder_id)).unwrap();
assert_eq!(scoped, vec![close_id, far_id]);
// Images without an embedding yield no matches instead of an error.
assert!(find_similar_image_ids(&conn, 9999, 5, None)
.unwrap()
.is_empty());
}
}
+72 -62
View File
@@ -1,90 +1,100 @@
import { useEffect } from "react"; import { useEffect } from 'react'
import { useGalleryStore } from "./store"; import { useGalleryStore } from './store'
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from './components/Sidebar'
import { BackgroundTasks } from "./components/BackgroundTasks"; import { BackgroundTasks } from './components/BackgroundTasks'
import { Toolbar } from "./components/Toolbar"; import { Toolbar } from './components/Toolbar'
import { Gallery } from "./components/Gallery"; import { Gallery } from './components/Gallery'
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from './components/Lightbox'
import { TagCloud } from "./components/TagCloud"; import { ExploreView } from './components/ExploreView'
import { DuplicateFinder } from "./components/DuplicateFinder"; import { DuplicateFinder } from './components/DuplicateFinder'
import { Timeline } from "./components/Timeline"; import { Timeline } from './components/Timeline'
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from './components/TitleBar'
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from './components/SettingsModal'
import { FolderPickerModal } from "./components/FolderPickerModal"; import { FolderPickerModal } from './components/FolderPickerModal'
import { UpdateToast } from "./components/UpdateToast"; import { UpdateToast } from './components/UpdateToast'
import { WhatsNewToast } from "./components/WhatsNewToast"; import { WhatsNewToast } from './components/WhatsNewToast'
import { WhatsNewModal } from "./components/WhatsNewModal"; import { WhatsNewModal } from './components/WhatsNewModal'
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { OnboardingOverlay } from './components/onboarding/OnboardingOverlay'
import { DemoPanel } from "./components/DemoPanel"; import { DemoPanel } from './dev/DemoPanel'
import { initializeNotifications } from "./notifications"; import { initializeNotifications } from './notifications'
export default function App() { export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders); const loadFolders = useGalleryStore((state) => state.loadFolders)
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress)
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages)
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus)
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus)
const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel)
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache)
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const loadAlbums = useGalleryStore((state) => state.loadAlbums)
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds)
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused)
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist)
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress)
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion)
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates)
const activeView = useGalleryStore((state) => state.activeView); 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(() => { useEffect(() => {
void initializeNotifications(); void initializeNotifications()
void loadMutedFolderIds(); void loadMutedFolderIds()
void loadNotificationsPaused(); void loadNotificationsPaused()
void loadFfmpegStatus(); void loadWorkerPausesPersist()
void loadOnboardingCompleted(); void loadFfmpegStatus()
void loadOnboardingCompleted()
// Load the app version first so the What's New toast/modal (which read // 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. // 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. // Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true }); void checkForUpdates({ quiet: true })
} }
loadFolders().then(() => { loadFolders().then(async () => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress()
void loadCaptionModelStatus(); void loadCaptionModelStatus()
void loadDuplicateScanCache(); void loadTaggerModel()
void loadAlbums(); void loadTaggerModelStatus()
return loadImages(true); void loadDuplicateScanCache()
}); await loadAlbums()
let unlisten: (() => void) | undefined; await loadImages(true)
if (import.meta.env.MODE === 'ui') {
const { applyMockScenario } = await import('./dev/applyMockScenario')
applyMockScenario()
}
})
let unlisten: (() => void) | undefined
subscribeToProgress().then((fn) => { subscribeToProgress().then((fn) => {
unlisten = fn; unlisten = fn
}); })
return () => { return () => {
unlisten?.(); unlisten?.()
}; }
}, []); }, [])
return ( return (
<div className="flex h-screen flex-col bg-gray-950 text-white overflow-hidden select-none"> <div className="flex h-screen flex-col overflow-hidden bg-gray-950 text-white select-none">
{/* Custom title bar — sits at the very top */} {/* Custom title bar — sits at the very top */}
<TitleBar /> <TitleBar />
{/* Main app content below the title bar */} {/* Main app content below the title bar */}
<div className="flex flex-1 min-h-0"> <div className="flex min-h-0 flex-1">
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex min-w-0 flex-1 flex-col">
{activeView === "timeline" ? ( {activeView === 'timeline' ? (
<> <>
<Toolbar /> <Toolbar />
<BackgroundTasks /> <BackgroundTasks />
<Timeline /> <Timeline />
</> </>
) : activeView === "explore" ? ( ) : activeView === 'explore' ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<TagCloud /> <ExploreView />
</> </>
) : activeView === "duplicates" ? ( ) : activeView === 'duplicates' ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<DuplicateFinder /> <DuplicateFinder />
@@ -108,5 +118,5 @@ export default function App() {
<OnboardingOverlay /> <OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />} {import.meta.env.DEV && <DemoPanel />}
</div> </div>
); )
} }
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { getChangelogForVersion } from './changelog'
describe('getChangelogForVersion', () => {
it('returns null for a null/undefined version', () => {
expect(getChangelogForVersion(null)).toBeNull()
expect(getChangelogForVersion(undefined)).toBeNull()
})
it('never surfaces the in-progress Unreleased section', () => {
expect(getChangelogForVersion('Unreleased')).toBeNull()
expect(getChangelogForVersion('unreleased')).toBeNull()
})
it('returns null for a version with no matching entry', () => {
expect(getChangelogForVersion('99.9.9')).toBeNull()
})
it('resolves a plain released version', () => {
const entry = getChangelogForVersion('0.1.1')
expect(entry?.version).toBe('0.1.1')
expect(entry?.date).toBe('2026-06-23')
})
it('strips a leading "v" from the version string', () => {
expect(getChangelogForVersion('v0.1.1')?.version).toBe('0.1.1')
})
it('strips dev/UI-lab build suffixes so they resolve to the base version', () => {
expect(getChangelogForVersion('0.1.1-dev')?.version).toBe('0.1.1')
expect(getChangelogForVersion('0.1.1-ui')?.version).toBe('0.1.1')
expect(getChangelogForVersion('0.1.1-beta.1')?.version).toBe('0.1.1')
})
})
+115 -51
View File
@@ -2,111 +2,175 @@
// data so the "What's New" UI can render it nicely instead of dumping markdown. // 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 // 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. // 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 { export interface ChangelogItem {
/** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */ /** 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. */ /** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
body: string; body: string
} }
export interface ChangelogSection { export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */ /** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string; title: string
items: ChangelogItem[]; items: ChangelogItem[]
} }
export interface ChangelogEntry { export interface ChangelogEntry {
version: string; version: string
date: string | null; date: string | null
sections: ChangelogSection[]; sections: ChangelogSection[]
} }
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]" // "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/; const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/
// "### Added" // "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/; const SECTION_HEADING = /^###\s+(.+?)\s*$/
// "- bullet text" // "- bullet text"
const BULLET = /^[-*]\s+(.*)$/; const BULLET = /^[-*]\s+(.*)$/
// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in. // 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.) // (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 { function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim(); const collapsed = text.replace(/\s+/g, ' ').trim()
const match = collapsed.match(LEAD); const match = collapsed.match(LEAD)
if (match) { 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[] { function parseChangelog(raw: string): ChangelogEntry[] {
const lines = raw.split(/\r?\n/); const lines = raw.split(/\r?\n/)
const entries: ChangelogEntry[] = []; const entries: ChangelogEntry[] = []
let entry: ChangelogEntry | null = null; let entry: ChangelogEntry | null = null
let section: ChangelogSection | null = null; let section: ChangelogSection | null = null
let buffer: string[] = []; let buffer: string[] = []
const flushItem = () => { const flushItem = () => {
if (section && buffer.length > 0) { if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" "))); section.items.push(toItem(buffer.join(' ')))
} }
buffer = []; buffer = []
}; }
for (const line of lines) { for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING); const versionMatch = line.match(VERSION_HEADING)
if (versionMatch) { if (versionMatch) {
flushItem(); flushItem()
section = null; section = null
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] }; entry = {
entries.push(entry); version: versionMatch[1].trim(),
continue; 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). // 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) { if (sectionMatch) {
flushItem(); flushItem()
section = { title: sectionMatch[1].trim(), items: [] }; section = { title: sectionMatch[1].trim(), items: [] }
entry.sections.push(section); entry.sections.push(section)
continue; continue
} }
const bulletMatch = line.match(BULLET); const bulletMatch = line.match(BULLET)
if (bulletMatch) { if (bulletMatch) {
flushItem(); flushItem()
buffer.push(bulletMatch[1]); buffer.push(bulletMatch[1])
continue; continue
} }
if (line.trim() === "") { if (line.trim() === '') {
// A blank line ends a (possibly wrapped) multi-line bullet. // A blank line ends a (possibly wrapped) multi-line bullet.
flushItem(); flushItem()
continue; continue
} }
// Indented continuation of the current wrapped bullet. // Indented continuation of the current wrapped bullet.
if (buffer.length > 0) { if (buffer.length > 0) {
buffer.push(line.trim()); buffer.push(line.trim())
} }
} }
flushItem(); flushItem()
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) })); return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }))
} }
const ENTRIES = parseChangelog(changelogRaw); const ENTRIES = parseChangelog(changelogRaw)
// Synthetic small release for UI Lab (`?changelog=small`). The What's New modal
// switches layout by release size, and every real entry in CHANGELOG.md is
// large — so the compact single-column layout can't be exercised from real
// data. This stays below the modal's rail threshold on purpose.
const SMALL_PREVIEW_ENTRY: ChangelogEntry = {
version: '0.0.0-preview',
date: '2026-01-01',
sections: [
{
title: 'Added',
items: [
{
lead: 'Sample setting',
body: 'a small toggle that exists purely so this preview has an Added section.',
},
],
},
{
title: 'Changed',
items: [
{
lead: 'Snappier previews',
body: 'the preview fixture now loads instantly, because it is made up.',
},
{ lead: null, body: 'A plain full-sentence bullet without a bold lead, for coverage.' },
],
},
{
title: 'Fixed',
items: [
{
lead: 'Hotfix-sized fix',
body: 'a believable one-liner about a bug that never shipped.',
},
{
lead: 'Another small fix',
body: 'keeps the total item count comfortably under the rail threshold.',
},
],
},
],
}
// UI Lab affordance (browser-only `ui` mode): `?changelog=` previews an entry
// other than the running version's, mirroring the `?scenario=` pattern.
// ?changelog=unreleased — the in-progress [Unreleased] notes (large release)
// ?changelog=small — synthetic hotfix-sized entry (compact layout)
// ?changelog=0.1.1 — any specific released version
function previewOverride(): ChangelogEntry | null {
if (import.meta.env.MODE !== 'ui') return null
const preview = new URLSearchParams(window.location.search).get('changelog')
if (!preview) return null
if (preview === 'small') return SMALL_PREVIEW_ENTRY
return ENTRIES.find((e) => e.version.toLowerCase() === preview.toLowerCase()) ?? null
}
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null { export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null; const override = previewOverride()
const normalized = version.replace(/^v/, ""); if (override) return override
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, '')
// Never surface the in-progress [Unreleased] section to users. // Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null; if (normalized.toLowerCase() === 'unreleased') return null
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null; return ENTRIES.find((e) => e.version.replace(/^v/, '') === normalized) ?? null
} }
+70
View File
@@ -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> | 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 (
<>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void onPick(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault()
void handleCreate()
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creating}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creating || !newAlbumName.trim()}
>
Add
</button>
</form>
</>
)
}
+114 -567
View File
@@ -1,608 +1,155 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from 'react'
import { invoke } from "@tauri-apps/api/core"; import { invoke } from '@tauri-apps/api/core'
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { useGalleryStore, type WorkerKey } from '../store'
import { useGalleryStore, WorkerKey } from "../store"; import { BackgroundTaskSummary } from './backgroundTasks/BackgroundTaskSummary'
import { ExpandedTaskPanel } from './backgroundTasks/ExpandedTaskPanel'
const WORKER_FOR_STAGE: Record<string, WorkerKey> = { import {
Thumbnails: "thumbnail", buildDuplicateScanTask,
Metadata: "metadata", buildFolderTasks,
Embeddings: "embedding", taskHasTerminalFailure,
Tags: "tagging", taskProgress,
}; } from './backgroundTasks/taskModel'
import type { BackgroundTask, FailedWorkerItem } from './backgroundTasks/types'
interface TaskStage {
label: string;
detail: string;
progress: number | null; // 0100, 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 (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
);
}
export function BackgroundTasks() { export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders)
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress)
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress)
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings)
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs)
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const showFailedTagging = useGalleryStore((state) => state.showFailedTagging)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const [expanded, setExpanded] = useState(false); const workerPaused = useGalleryStore((state) => state.workerPaused)
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates)
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({}); const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused)
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({}); const [expanded, setExpanded] = useState(false)
const [dismissed, setDismissed] = useState<Record<number, string>>({})
const workerPaused = useGalleryStore((state) => state.workerPaused); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); Record<number, FailedWorkerItem[]>
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused); >({})
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>(
{}
)
useEffect(() => { useEffect(() => {
void loadWorkerStates(); void loadWorkerStates()
}, [folders, loadWorkerStates]); }, [folders, loadWorkerStates])
// Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedEmbeddingCounts = useMemo( const failedEmbeddingCounts = useMemo(
() => () =>
Object.fromEntries( 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( const failedTaggingCounts = useMemo(
() => () =>
Object.fromEntries( 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(() => { useEffect(() => {
if (!expanded) return; if (!expanded) return
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", { invoke<FailedWorkerItem[]>('get_failed_embedding_images', {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined)
} }
} }
for (const [folderId, count] of Object.entries(failedTaggingCounts)) { for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", { invoke<FailedWorkerItem[]>('get_failed_tagging_images', {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined)
} }
} }
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]); }, [expanded, failedEmbeddingCounts, failedTaggingCounts])
const isWorkerPaused = (folderId: number, worker: WorkerKey) => { const folderTasks = useMemo(
return workerPaused[folderId]?.[worker] ?? false; () =>
}; 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) => { const toggleWorker = (folderId: number, worker: WorkerKey) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker)); setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker))
}; }
const dismissTask = (id: number, snapshot: string) => { const dismissTask = (task: BackgroundTask) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed if (task.id < 0) return
setDismissed((prev) => ({ ...prev, [id]: snapshot })); setDismissed((prev) => ({ ...prev, [task.id]: task.snapshot }))
setExpanded(false); setExpanded(false)
}; }
const tasks = useMemo<Task[]>(() => { const retryTask = (task: BackgroundTask) => {
return folders if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id)
.map((folder): Task | null => { if (task.hasFailedTagging) void queueTaggingJobs(task.id)
const index = indexingProgress[folder.id]; }
const jobs = mediaJobProgress[folder.id];
const thumbnailPending = jobs?.thumbnail_pending ?? 0; const primary = allTasks[0]
const metadataPending = jobs?.metadata_pending ?? 0; const hasFailed = folderTasks.some(taskHasTerminalFailure)
const embeddingPending = jobs?.embedding_pending ?? 0; const barProgress = taskProgress(primary)
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;
return ( return (
<div className="shrink-0 border-b border-white/[0.06]"> <div className="shrink-0 border-b border-white/[0.06]">
{/* Slim bar */} <BackgroundTaskSummary
<div expanded={expanded}
className={`group flex items-center gap-3 px-5 h-11 cursor-pointer select-none transition-colors ${ extraCount={allTasks.length - 1}
expanded ? "bg-white/[0.03]" : "hover:bg-white/[0.02]" hasFailed={hasFailed}
}`} isWorkerPaused={isWorkerPaused}
onClick={() => setExpanded((v) => !v)} onDismiss={dismissTask}
> onLocate={showFailedTagging}
{/* Pulse dot */} onRetry={retryTask}
<div className="relative shrink-0"> onToggleExpanded={() => setExpanded((value) => !value)}
<div className={`h-1.5 w-1.5 rounded-full ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} /> onToggleWorker={toggleWorker}
<div className={`absolute inset-0 h-1.5 w-1.5 rounded-full animate-ping opacity-60 ${hasFailed ? "bg-amber-400" : "bg-blue-400"}`} /> primary={primary}
</div> progress={barProgress}
taskCount={allTasks.length}
/>
{/* Folder name */} {expanded ? (
<span className="text-[13px] font-medium text-white/60 shrink-0">{primary.name}</span> <ExpandedTaskPanel
failedEmbeddingItems={failedEmbeddingItems}
{/* Stage tags — all active stages visible simultaneously */} failedTaggingItems={failedTaggingItems}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden"> isWorkerPaused={isWorkerPaused}
{primary.stages.map((stage) => { onDismiss={dismissTask}
const workerKey = WORKER_FOR_STAGE[stage.label]; onLocate={showFailedTagging}
const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false; onRetry={retryTask}
return ( onToggleWorker={toggleWorker}
<span tasks={allTasks}
key={stage.label} />
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ ) : null}
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400"
}`}
>
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<button
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
>
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
)}
</span>
);
})}
</div>
{/* Progress bar — embedding or scanning progress, pulsing if indeterminate */}
<div className="w-24 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
hasFailed
? "bg-amber-400/60"
: barProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={barProgress !== null ? { width: `${barProgress}%` } : undefined}
/>
</div>
{/* Extra folders badge */}
{extraCount > 0 && (
<span className="rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-gray-500 shrink-0">
+{extraCount}
</span>
)}
{primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
<div className="flex shrink-0 items-center gap-1.5">
{primary.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => { e.stopPropagation(); showFailedTagging(primary.id); }}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => {
e.stopPropagation();
if (primary.hasFailedEmbeddings) void retryFailedEmbeddings(primary.id);
if (primary.hasFailedTagging) void queueTaggingJobs(primary.id);
}}
>
Retry
</button>
</div>
)}
{/* Expand chevron (only when multiple tasks) */}
{allTasks.length > 1 && (
<svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
{/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={(e) => { e.stopPropagation(); dismissTask(primary.id, primary.snapshot); }}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Expanded panel — one row per folder */}
{expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{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 (
<div key={task.id}>
<div className="flex items-center gap-3">
<span className="text-[12px] text-white/50 w-28 truncate shrink-0">{task.name}</span>
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{task.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false;
return (
<span
key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed
? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused
? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500"
}`}
>
{isPaused && (
<svg className="h-2 w-2 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<button
className="ml-0.5 text-gray-600 hover:text-white transition-colors"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={() => toggleWorker(task.id, workerKey)}
>
{isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
)}
</button>
)}
</span>
);
})}
</div>
<div className="w-20 h-px bg-white/8 rounded-full overflow-hidden shrink-0">
<div
className={`h-full rounded-full transition-all duration-500 ${
taskHasFailed
? "bg-amber-400/60"
: taskBarProgress === null
? "bg-blue-500/40 animate-pulse w-full"
: "bg-blue-500"
}`}
style={taskBarProgress !== null ? { width: `${taskBarProgress}%` } : undefined}
/>
</div>
{taskHasFailed && (
<div className="flex shrink-0 items-center gap-1.5">
{task.hasFailedTagging ? (
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => showFailedTagging(task.id)}
>
Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
>
Retry
</button>
</div>
)}
{task.id >= 0 && (
<button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss"
onClick={() => dismissTask(task.id, task.snapshot)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{task.currentFile && (
<p className="text-[10px] text-gray-600 truncate mt-1 pl-[calc(7rem+0.75rem)]">
{task.currentFile}
</p>
)}
{/* Failed worker file lists */}
{taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
)}
{taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedTaggingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
)}
</div>
);
})}
</div>
)}
</div> </div>
); )
} }
+102 -217
View File
@@ -1,88 +1,67 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { BulkTagPopover } from "./bulk/BulkTagPopover"; import { BulkAlbumPopover } from './bulk/BulkAlbumPopover'
import { BulkDeleteConfirm } from './bulk/BulkDeleteConfirm'
type Panel = "tag" | "rating" | "album" | "delete" | null; 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() { export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size); const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size)
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds); const selectedIds = useGalleryStore((state) => state.gallerySelectedIds)
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection); const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection)
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery); const selectAllGallery = useGalleryStore((state) => state.selectAllGallery)
const loadedCount = useGalleryStore((state) => state.loadedCount); const loadedCount = useGalleryStore((state) => state.loadedCount)
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages)
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite); const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite)
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating); const bulkSetRating = useGalleryStore((state) => state.bulkSetRating)
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected); const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected)
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView)
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId); const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId)
const albums = useGalleryStore((state) => state.albums); const addToAlbum = useGalleryStore((state) => state.addToAlbum)
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum)
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const [panel, setPanel] = useState<Panel>(null); const [panel, setPanel] = useState<BulkPanel>(null)
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false)
const [creatingAlbum, setCreatingAlbum] = useState(false); const barRef = useRef<HTMLDivElement>(null)
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
// Close any open popover when clicking outside the bar. // Close any open popover when clicking outside the bar or pressing Escape.
useEffect(() => { useDismissable(barRef, () => setPanel(null), panel !== null)
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Reset transient UI whenever the selection empties. // Reset transient UI whenever the selection empties.
useEffect(() => { useEffect(() => {
if (selectedCount === 0) { if (selectedCount === 0) setPanel(null)
setPanel(null); }, [selectedCount])
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) return null; if (selectedCount === 0) return null
const ids = Array.from(selectedIds); const ids = Array.from(selectedIds)
const inAlbumView = activeView === "album" && selectedAlbumId !== null; const inAlbumView = activeView === 'album' && selectedAlbumId !== null
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next)); const togglePanel = (next: Exclude<BulkPanel, null>) =>
setPanel((current) => (current === next ? null : next))
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true)
try { try {
await bulkDeleteSelected(); await bulkDeleteSelected()
} finally { } finally {
setDeleting(false); setDeleting(false)
setPanel(null); setPanel(null)
} }
}; }
const handlePickAlbum = async (albumId: number) => { const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids); await addToAlbum(albumId, ids)
setPanel(null); setPanel(null)
}; }
const handleCreateAlbum = async () => { const btn = 'rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors'
const name = newAlbumName.trim(); const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`
if (!name || creatingAlbum) return; const btnActive = `${btn} bg-white/10 text-white`
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`;
return ( return (
<div <div
@@ -90,121 +69,49 @@ export function BulkActionBar() {
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur" className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-center gap-2 px-1.5"> <BulkSelectionSummary
<span className="text-xs font-medium text-white">{selectedCount} selected</span> loadedCount={loadedCount}
{loadedCount < totalImages || loadedCount > selectedCount ? ( selectedCount={selectedCount}
<button totalImages={totalImages}
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300" onSelectAll={selectAllGallery}
onClick={selectAllGallery} />
title={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
) : null}
</div>
<div className="h-5 w-px bg-white/10" /> <div className="h-5 w-px bg-white/10" />
<div className="relative"> <div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}> <button
className={panel === 'tag' ? btnActive : btnIdle}
onClick={() => togglePanel('tag')}
>
Tag Tag
</button> </button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null} {panel === 'tag' ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div> </div>
<div className="relative"> <div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}> <button
className={panel === 'rating' ? btnActive : btnIdle}
onClick={() => togglePanel('rating')}
>
Rating Rating
</button> </button>
{panel === "rating" ? ( {panel === 'rating' ? (
<div <BulkRatingPopover onSetRating={bulkSetRating} onClose={() => setPanel(null)} />
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={async () => {
await bulkSetRating(rating);
setPanel(null);
}}
title={`Set ${rating} star${rating === 1 ? "" : "s"}`}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
);
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={async () => {
await bulkSetRating(0);
setPanel(null);
}}
>
Clear
</button>
</div>
) : null} ) : null}
</div> </div>
<Tooltip label="Mark as favorite" followCursor>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)} title="Mark as favorite"> <button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
Favorite Favorite
</button> </button>
</Tooltip>
<div className="relative"> <div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}> <button
className={panel === 'album' ? btnActive : btnIdle}
onClick={() => togglePanel('album')}
>
Add to album Add to album
</button> </button>
{panel === "album" ? ( {panel === 'album' ? <BulkAlbumPopover onPick={handlePickAlbum} /> : null}
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void handlePickAlbum(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreateAlbum();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creatingAlbum}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creatingAlbum || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div> </div>
{inAlbumView ? ( {inAlbumView ? (
@@ -219,58 +126,36 @@ export function BulkActionBar() {
<div className="h-5 w-px bg-white/10" /> <div className="h-5 w-px bg-white/10" />
<div className="relative"> <div className="relative">
<button <Tooltip label="Delete files from disk" followCursor>
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`} <button
onClick={() => togglePanel("delete")} className={
disabled={deleting} panel === 'delete'
title="Delete files from disk" ? `${btn} bg-red-500/15 text-red-300`
> : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`
{deleting ? "Deleting…" : "Delete"} }
</button> onClick={() => togglePanel('delete')}
{panel === "delete" ? ( disabled={deleting}
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
> >
<div className="mb-1 flex items-center gap-1.5 text-red-300"> {deleting ? 'Deleting…' : 'Delete'}
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} </Tooltip>
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> {panel === 'delete' ? (
</svg> <BulkDeleteConfirm
<p className="text-xs font-semibold">Delete from disk</p> deleting={deleting}
</div> selectedCount={selectedCount}
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400"> onCancel={() => setPanel(null)}
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer. onDelete={handleDelete}
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone. />
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setPanel(null)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
) : null} ) : null}
</div> </div>
<Tooltip label="Clear selection" followCursor>
<button <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection} onClick={clearGallerySelection}
title="Clear selection" >
> <CloseIcon className="h-4 w-4" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> </button>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </Tooltip>
</svg>
</button>
</div> </div>
); )
} }
+113 -92
View File
@@ -1,75 +1,82 @@
import { useEffect, useRef, useState } from "react"; import { useRef, useState } from 'react'
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from 'framer-motion'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { Tooltip } from "./Tooltip"; 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 // Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket. // distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [ const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] }, { name: 'Red', rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] }, { name: 'Orange', rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] }, { name: 'Yellow', rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] }, { name: 'Green', rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] }, { name: 'Teal', rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] }, { name: 'Blue', rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] }, { name: 'Purple', rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] }, { name: 'Pink', rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] }, { name: 'Brown', rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] }, { name: 'Black', rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] }, { name: 'White', rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] }, { name: 'Gray', rgb: [154, 160, 166] },
]; ]
function rgbEquals(a: Rgb | null, b: Rgb): boolean { 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 { 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 { function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16); const n = parseInt(hex.slice(1), 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
} }
export function ColorFilter() { export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter); const colorFilter = useGalleryStore((state) => state.colorFilter)
const setColorFilter = useGalleryStore((state) => state.setColorFilter); const setColorFilter = useGalleryStore((state) => state.setColorFilter)
const colorBackfill = useGalleryStore((state) => state.colorBackfill); const colorBackfill = useGalleryStore((state) => state.colorBackfill)
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null)
const isActive = colorFilter !== null; const isActive = colorFilter !== null
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb)); const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb))
// Collapse the panel when clicking elsewhere. // Collapse the panel when clicking elsewhere or pressing Escape.
useEffect(() => { useDismissable(ref, () => setOpen(false), open)
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]);
return ( return (
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2"> <div
ref={ref}
className="relative ml-1 flex shrink-0 items-center border-l border-white/6 pl-2"
>
{/* Trigger a single palette icon; shows the active color as a dot when a {/* Trigger a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */} filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}> <Tooltip
label={isActive ? 'Color filter active' : 'Filter by color'}
delay={400}
anchorToCursor
>
<button <button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${ className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200" open || isActive
? 'bg-white/10 text-white'
: 'text-gray-500 hover:bg-white/5 hover:text-gray-200'
}`} }`}
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
aria-label="Filter by color" aria-label="Filter by color"
> >
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6} <path
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" /> strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z"
/>
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" /> <circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" /> <circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" /> <circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
@@ -92,65 +99,79 @@ export function ColorFilter() {
initial={{ opacity: 0, y: -4, scale: 0.98 }} initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }} exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }} transition={{ duration: 0.14, ease: 'easeOut' }}
className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50" className="light-theme:border-gray-700/50 absolute top-full right-0 z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur"
> >
<div className="grid grid-cols-7 gap-1.5"> <div className="grid grid-cols-7 gap-1.5">
{SWATCHES.map((swatch) => { {SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb); const active = rgbEquals(colorFilter, swatch.rgb)
return ( return (
<button <Tooltip label={swatch.name} followCursor>
key={swatch.name} <button
title={swatch.name} key={swatch.name}
aria-label={`Filter by ${swatch.name}`} aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${ className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" active
}`} ? 'scale-110 border-white/40 ring-2 ring-white/70'
style={{ backgroundColor: toHex(swatch.rgb) }} : 'border-white/15 hover:scale-110'
onClick={() => setColorFilter(active ? null : swatch.rgb)} }`}
/> style={{ backgroundColor: toHex(swatch.rgb) }}
); onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
</Tooltip>
)
})} })}
<Tooltip label="Custom Colour" followCursor>
{/* Custom color picker — rainbow until a custom color is chosen. */} {/* Custom color picker — rainbow until a custom color is chosen. */}
<label <label
title="Custom color" className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${ isCustom
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110" ? 'border-white/40 ring-2 ring-white/70'
}`} : 'border-white/15 hover:scale-110'
style={ }`}
isCustom style={
? { backgroundColor: toHex(colorFilter as Rgb) } isCustom
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" } ? { backgroundColor: toHex(colorFilter as Rgb) }
} : {
> background:
<input 'conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)',
type="color" }
className="absolute inset-0 cursor-pointer opacity-0" }
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"} >
onChange={(event) => setColorFilter(fromHex(event.target.value))} <input
/> type="color"
</label> className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : '#3b7dd8'}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
</Tooltip>
</div> </div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( {isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40"> <div className="light-theme:border-gray-700/40 mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2">
{colorBackfill && colorBackfill.total > 0 ? ( {colorBackfill && colorBackfill.total > 0 ? (
<span <Tooltip
className="text-[10px] text-gray-600" label="Sampling colours from existing thumbnails — colour search fills in as this runs"
title="Sampling colors from existing thumbnails — color search fills in as this runs" anchorToCursor
> >
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()} <span className="text-[10px] text-gray-600">
</span> sampling {colorBackfill.processed.toLocaleString()}/
) : <span />} {colorBackfill.total.toLocaleString()}
</span>
</Tooltip>
) : (
<span />
)}
{isActive ? ( {isActive ? (
<button <Tooltip label="Clear colour filter" anchorToCursor>
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200" <button
onClick={() => setColorFilter(null)} className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
title="Clear color filter" onClick={() => setColorFilter(null)}
> >
Clear Clear
</button> </button>
</Tooltip>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -158,5 +179,5 @@ export function ColorFilter() {
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</div> </div>
); )
} }
+76 -309
View File
@@ -1,358 +1,125 @@
import { useRef, useState } from "react"; import { useRef, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { convertFileSrc } from "@tauri-apps/api/core"; import { useGalleryStore } from '../store'
import { DuplicateGroup, useGalleryStore } from "../store"; import {
import { FolderScopeDropdown } from "./FolderScopeDropdown"; DuplicateScanEmptyState,
DuplicateScanIntroState,
function formatBytes(bytes: number): string { DuplicateScanLoadingState,
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; } from './duplicateFinder/DuplicateFinderEmptyStates'
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; import { DuplicateFinderHeader } from './duplicateFinder/DuplicateFinderHeader'
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; import { DuplicateGroupCard } from './duplicateFinder/DuplicateGroupCard'
return `${bytes} B`; import { duplicateProgressLabel } from './duplicateFinder/format'
}
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 (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
{/* Group header */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={() => {
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
}}
>
Deselect all
</button>
)}
</div>
</div>
{/* Image grid */}
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
key={image.id}
className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
title={image.path}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{/* Delete overlay */}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
) : null}
{/* Path tooltip on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
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`;
}
export function DuplicateFinder() { export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups); const duplicateGroups = useGalleryStore((state) => state.duplicateGroups)
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning)
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress)
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError); const duplicateScanError = useGalleryStore((state) => state.duplicateScanError)
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning); const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning)
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds); const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds)
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned); const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned)
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates); const scanDuplicates = useGalleryStore((state) => state.scanDuplicates)
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection); const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection)
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups); const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups)
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates)
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleteResult, setDeleteResult] = useState<string | null>(null); const [deleteResult, setDeleteResult] = useState<string | null>(null)
// Virtualize the group list so a large result set (e.g. thousands of pairs) // 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 // only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically. // copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: duplicateGroups.length, count: duplicateGroups.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => scrollRef.current,
estimateSize: () => 220, estimateSize: () => 220,
overscan: 4, 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 () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true)
setConfirmingDelete(false); setConfirmingDelete(false)
setDeleteResult(null); setDeleteResult(null)
try { try {
const deleted = await deleteSelectedDuplicates(); const deleted = await deleteSelectedDuplicates()
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`); setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? '' : 's'}.`)
} catch (e) { } catch (e) {
setDeleteResult(String(e)); setDeleteResult(String(e))
} finally { } finally {
setDeleting(false); setDeleting(false)
} }
}; }
const progressPercent = const progressLabel = duplicateProgressLabel(duplicateScanProgress)
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;
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
{/* Header */} <DuplicateFinderHeader
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4"> confirmingDelete={confirmingDelete}
<div className="flex items-center justify-between gap-4"> deleteResult={deleteResult}
<div> deleting={deleting}
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2> duplicateGroups={duplicateGroups}
<p className="mt-0.5 text-[11px] text-white/30"> duplicateLastScanned={duplicateLastScanned}
{duplicateScanning duplicateScanError={duplicateScanError}
? duplicateScanProgress duplicateScanning={duplicateScanning}
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}` duplicateScanProgress={duplicateScanProgress}
: "Starting scan…" duplicateScanWarning={duplicateScanWarning}
: hasResults hasResults={hasResults}
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable` hasScanned={hasScanned}
: duplicateLastScanned !== null onClearSelection={clearDuplicateSelection}
? "No duplicates found" onConfirmDelete={() => setConfirmingDelete(false)}
: "Scan your library for identical files"} onDelete={handleDelete}
</p> onScan={() => {
{!duplicateScanning && duplicateLastScanned !== null && ( setDeleteResult(null)
<p className="mt-0.5 text-[10px] text-white/20"> void scanDuplicates(selectedFolderId)
Last scanned {formatRelativeTime(duplicateLastScanned)} }}
</p> onSelectKeepFirstAll={selectKeepFirstAllGroups}
)} selectedCount={selectedCount}
</div> setConfirmingDelete={setConfirmingDelete}
<div className="flex items-center gap-2"> />
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
Select all duplicates
</button>
)}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={clearDuplicateSelection}
disabled={deleting}
>
Deselect all
</button>
<div className="relative">
<button
className="rounded-lg border border-red-400/25 bg-red-500/10 px-3 py-1.5 text-xs text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingDelete((v) => !v)}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
{confirmingDelete && !deleting ? (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setConfirmingDelete(false)} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={handleDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</>
) : null}
<button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
</button>
</div>
</div>
{/* Progress bar */}
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
{duplicateScanError ? (
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
) : null}
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div>
{/* Body */}
{duplicateScanning && !hasResults ? ( {duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25"> <DuplicateScanLoadingState progressLabel={progressLabel} />
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span>
</div>
) : !hasScanned ? ( ) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8"> <DuplicateScanIntroState />
<div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
) : duplicateGroups.length === 0 ? ( ) : duplicateGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center"> <DuplicateScanEmptyState />
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
) : ( ) : (
<div ref={scrollRef} className="overflow-y-auto px-6 py-5"> <div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const group = duplicateGroups[virtualItem.index]; const group = duplicateGroups[virtualItem.index]
if (!group) return null; if (!group) return null
return ( return (
<div <div
key={group.file_hash} key={group.file_hash}
data-index={virtualItem.index} data-index={virtualItem.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
style={{ style={{
position: "absolute", position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: '100%',
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16, paddingBottom: 16,
}} }}
> >
<DuplicateGroupCard group={group} /> <DuplicateGroupCard group={group} />
</div> </div>
); )
})} })}
</div> </div>
</div> </div>
)} )}
</div> </div>
); )
} }
+154
View File
@@ -0,0 +1,154 @@
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)
// 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 tagManagerScopeLabel =
selectedFolderId === null
? '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
}
useEffect(() => {
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)
return (
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header `relative z-10` keeps the folder-scope dropdown above the
cluster canvas, whose cards use a high z-index of their own. */}
<div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
{loading
? exploreMode === 'visual'
? 'Computing visual clusters…'
: 'Loading tags…'
: hasEntries
? 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'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{exploreMode === 'tags' && hasEntries ? (
<button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? 'border-white/15 bg-white/10 text-white'
: 'border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300'
}`}
onClick={() => setManageTags(!manageTags)}
>
{manageTags ? 'Done' : 'Manage'}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === 'visual'
? 'bg-white/10 text-white'
: 'text-gray-500 hover:text-gray-300'
}`}
onClick={() => setExploreMode('visual')}
>
Clusters
</button>
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === 'tags'
? 'bg-white/10 text-white'
: 'text-gray-500 hover:text-gray-300'
}`}
onClick={() => setExploreMode('tags')}
>
Tag Cloud
</button>
</div>
</div>
</div>
</div>
{loading && !hasEntries ? (
<ExploreLoadingPanel mode={exploreMode} />
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
{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.'}
</p>
</div>
) : exploreMode === 'visual' ? (
<div className="relative flex min-h-0 flex-1 flex-col">
<ClusterCloud entries={visualClusterEntries} onOpen={showVisualCluster} />
</div>
) : manageTags ? (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
onResetAiTags={handleResetAiTags}
scopeLabel={tagManagerScopeLabel}
/>
</div>
) : (
<TagAtlas
entries={exploreTagEntries}
onSearch={searchForTag}
loadRelatedTags={loadRelatedTags}
/>
)}
</div>
)
}
+140 -366
View File
@@ -1,342 +1,50 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from '@tanstack/react-virtual'
import { useVirtualizer } from "@tanstack/react-virtual"; import { Tooltip } from './Tooltip'
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store"; import { CloseIcon } from './icons'
import { FolderRow } from './folderPicker/FolderRow'
function normalizePath(path: string): string { import { StagedFoldersPanel } from './folderPicker/StagedFoldersPanel'
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase(); import { StatusLine } from './folderPicker/StatusLine'
} import { normalizePath } from './folderPicker/pathUtils'
import { useFolderPicker } from './folderPicker/useFolderPicker'
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 (
<p className="text-xs text-gray-500 light-theme:text-gray-600">
Added {added}, skipped {skipped}, failed {failed}.
</p>
);
}
function FolderRow({
entry,
selected,
alreadyAdded,
onToggle,
onNavigate,
}: {
entry: DirEntry;
selected: boolean;
alreadyAdded: boolean;
onToggle: () => void;
onNavigate: () => void;
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
: selected
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<button
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
onClick={onToggle}
disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={onNavigate}
title={entry.path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
</button>
{alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
Added
</span>
) : null}
<button
type="button"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={onNavigate}
title={entry.has_children ? "Open folder" : "No subfolders"}
>
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[];
onRemove: (path: string) => void;
onClear: () => void;
}) {
return (
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={onClear}
>
Clear all
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? (
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
<p className="text-sm text-gray-500">No folders selected.</p>
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
Choose folders on the left and they will collect here.
</p>
</div>
) : (
<div className="space-y-2">
{stagedPaths.map((path) => (
<div
key={path}
className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
title={path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
</div>
<button
type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
title="Remove from folders to add"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</aside>
);
}
export function FolderPickerModal() { export function FolderPickerModal() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen); const folderPicker = useFolderPicker()
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<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FolderAddResult[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(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 virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: listing?.entries.length ?? 0, count: folderPicker.entries.length,
getScrollElement: () => scrollRef.current, getScrollElement: () => folderPicker.scrollRef.current,
estimateSize: () => 48, estimateSize: () => 48,
overscan: 8, overscan: 8,
}); })
useEffect(() => { if (!folderPicker.folderPickerOpen) return null
if (!folderPickerOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return;
setListing(nextListing);
scrollRef.current?.scrollTo({ top: 0, left: 0 });
})
.catch((loadError) => {
if (cancelled) return;
setListing({ current: currentPath, parent: null, entries: [] });
setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [currentPath, folderPickerOpen, listDirectories]);
useEffect(() => {
if (!folderPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setFolderPickerOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [folderPickerOpen, setFolderPickerOpen]);
useEffect(() => {
if (folderPickerOpen) return;
setCurrentPath(null);
setListing(null);
setStagedPaths([]);
setError(null);
setResults(null);
setAdding(false);
}, [folderPickerOpen]);
if (!folderPickerOpen) return null;
const entries = listing?.entries ?? [];
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 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);
}
};
return ( return (
<div <div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setFolderPickerOpen(false)} onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
<div <div
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70" className="light-theme:border-gray-300/70 relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200"> <header className="light-theme:border-gray-200 border-b border-white/[0.07] px-5 py-4">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-base font-semibold text-white">Add media folders</p> <p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p> <p className="light-theme:text-gray-600 mt-1 text-xs text-gray-500">
Choose folders from any location, then add them together.
</p>
</div> </div>
<button <Tooltip label="Close folder picker" anchorToCursor>
type="button" <button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white" type="button"
onClick={() => setFolderPickerOpen(false)} className="light-theme:hover:bg-gray-900 light-theme:hover:text-white rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
title="Close folder picker" onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <CloseIcon className="h-4 w-4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </button>
</svg> </Tooltip>
</button>
</div> </div>
</header> </header>
@@ -345,52 +53,118 @@ export function FolderPickerModal() {
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<button <button
type="button" type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setCurrentPath(listing?.parent ?? null)} onClick={() => folderPicker.setCurrentPath(folderPicker.listing?.parent ?? null)}
disabled={!listing?.current} disabled={!folderPicker.listing?.current}
> >
Up Up
</button> </button>
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900"> {folderPicker.addressEditing ? (
{breadcrumbs.map((crumb, index) => ( <form
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1"> className="flex min-w-0 flex-1 items-center gap-2"
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null} onSubmit={(event) => {
<button event.preventDefault()
type="button" folderPicker.navigateToAddress()
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white" }}
onClick={() => setCurrentPath(crumb.path)} >
title={crumb.path ?? "Roots"} <label className="sr-only" htmlFor="folder-picker-address">
> Folder path
{crumb.label} </label>
</button> <input
</span> ref={folderPicker.addressInputRef}
))} id="folder-picker-address"
</nav> 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}
/>
<button
type="submit"
className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={folderPicker.loading}
>
Go
</button>
</form>
) : (
<div className="light-theme:border-gray-300/70 light-theme:bg-gray-900 flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5">
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
{folderPicker.breadcrumbs.map((crumb, index) => (
<span
key={`${crumb.path ?? 'root'}-${index}`}
className="flex min-w-0 items-center gap-1"
>
{index > 0 ? (
<span className="light-theme:text-gray-400 text-gray-700">/</span>
) : null}
<Tooltip label={crumb.path ?? 'Roots'} anchorToCursor>
<button
type="button"
className="light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={(event) => {
event.stopPropagation()
folderPicker.setCurrentPath(crumb.path)
}}
>
{crumb.label}
</button>
</Tooltip>
</span>
))}
</nav>
<button
type="button"
className="min-w-10 flex-1 cursor-text self-stretch rounded px-1"
onClick={folderPicker.beginAddressEdit}
aria-label="Edit folder path"
/>
</div>
)}
<button
type="button"
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-2.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => folderPicker.stagePath(folderPicker.addressPath)}
disabled={
!folderPicker.addressPath ||
folderPicker.addressAlreadyAdded ||
folderPicker.addressAlreadyStaged
}
>
Select
</button>
</div> </div>
{error ? ( {folderPicker.error ? (
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800"> <div className="light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800 mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
{error} {folderPicker.error}
</div> </div>
) : null} ) : null}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50"> <div
{loading ? ( ref={folderPicker.scrollRef}
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div> className="light-theme:border-gray-300/70 light-theme:bg-gray-900/50 min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2"
) : entries.length === 0 ? ( >
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div> {folderPicker.loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Loading folders...
</div>
) : folderPicker.entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
No folders found here.
</div>
) : ( ) : (
<div <div
className="relative w-full" className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }} style={{ height: `${virtualizer.getTotalSize()}px` }}
> >
{virtualizer.getVirtualItems().map((virtualItem) => { {virtualizer.getVirtualItems().map((virtualItem) => {
const entry = entries[virtualItem.index]; const entry = folderPicker.entries[virtualItem.index]
const normalized = normalizePath(entry.path); const normalized = normalizePath(entry.path)
return ( return (
<div <div
key={virtualItem.key} key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5" className="absolute top-0 left-0 w-full px-0.5"
style={{ style={{
height: `${virtualItem.size}px`, height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`, transform: `translateY(${virtualItem.start}px)`,
@@ -398,13 +172,13 @@ export function FolderPickerModal() {
> >
<FolderRow <FolderRow
entry={entry} entry={entry}
selected={stagedSet.has(normalized)} selected={folderPicker.stagedSet.has(normalized)}
alreadyAdded={libraryPaths.has(normalized)} alreadyAdded={folderPicker.libraryPaths.has(normalized)}
onToggle={() => togglePath(entry.path)} onToggle={() => folderPicker.togglePath(entry.path)}
onNavigate={() => setCurrentPath(entry.path)} onNavigate={() => folderPicker.setCurrentPath(entry.path)}
/> />
</div> </div>
); )
})} })}
</div> </div>
)} )}
@@ -412,38 +186,38 @@ export function FolderPickerModal() {
</main> </main>
<StagedFoldersPanel <StagedFoldersPanel
stagedPaths={stagedPaths} stagedPaths={folderPicker.stagedPaths}
onRemove={removeStagedPath} onRemove={folderPicker.removeStagedPath}
onClear={clearStagedPaths} onClear={folderPicker.clearStagedPaths}
/> />
</div> </div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200"> <footer className="light-theme:border-gray-200 border-t border-white/[0.07] px-5 py-4">
<div className="flex items-end justify-between gap-4"> <div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<StatusLine results={results} /> <StatusLine results={folderPicker.results} />
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<button <button
type="button" type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={() => setFolderPickerOpen(false)} onClick={() => folderPicker.setFolderPickerOpen(false)}
> >
Cancel Cancel
</button> </button>
<button <button
type="button" type="button"
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800" className="light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void confirmAdd()} onClick={() => void folderPicker.confirmAdd()}
disabled={stagedPaths.length === 0 || adding} disabled={folderPicker.stagedPaths.length === 0 || folderPicker.adding}
> >
{adding ? "Adding..." : `Add ${stagedPaths.length}`} {folderPicker.adding ? 'Adding...' : `Add ${folderPicker.stagedPaths.length}`}
</button> </button>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
</div> </div>
); )
} }
+41 -84
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useMemo } from 'react'
import { useGalleryStore } from "../store"; import { useGalleryStore } from '../store'
import { Dropdown, DropdownOption } from './menu'
/** /**
* In-view folder scope picker for feature views (Timeline / Explore / * In-view folder scope picker for feature views (Timeline / Explore /
@@ -7,92 +8,48 @@ import { useGalleryStore } from "../store";
* current view active unlike sidebar folder clicks, which jump to Gallery. * current view active unlike sidebar folder clicks, which jump to Gallery.
*/ */
export function FolderScopeDropdown() { export function FolderScopeDropdown() {
const [open, setOpen] = useState(false); const folders = useGalleryStore((state) => state.folders)
const ref = useRef<HTMLDivElement>(null); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId)
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope)
const folders = useGalleryStore((state) => state.folders); const options = useMemo<DropdownOption<number | null>[]>(
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); () => [
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope); { value: null, label: 'All Media' },
...folders.map((folder) => ({
useEffect(() => { value: folder.id,
const close = (e: MouseEvent) => { label: folder.name,
if (!ref.current?.contains(e.target as Node)) setOpen(false); hint: <span className="tabular-nums">{folder.image_count.toLocaleString()}</span>,
}; })),
window.addEventListener("pointerdown", close); ],
return () => window.removeEventListener("pointerdown", close); [folders]
}, []); )
const currentLabel =
selectedFolderId === null
? "All Media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
return ( return (
<div ref={ref} className="feature-scope-dropdown relative"> <Dropdown
<button value={selectedFolderId}
onClick={() => setOpen((v) => !v)} options={options}
className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ onChange={setViewFolderScope}
open ariaLabel="Folder scope"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white" trigger="ghost"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white" size="md"
}`} triggerTooltip="Change folder scope"
title="Change folder scope" triggerClassName="max-w-56"
> panelClassName="min-w-52 max-h-80 overflow-y-auto"
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> triggerIcon={
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="truncate">{currentLabel}</span>
<svg <svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`} className="h-3.5 w-3.5 shrink-0 text-gray-500"
fill="none" viewBox="0 0 24 24" stroke="currentColor" fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg> </svg>
</button> }
{open ? ( />
<div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50"> )
<button
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
} }
+124 -466
View File
@@ -1,336 +1,54 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from 'react'
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from '@tanstack/react-virtual'
import { convertFileSrc } from "@tauri-apps/api/core"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from '../store'
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { BulkActionBar } from './BulkActionBar'
import { BulkActionBar } from "./BulkActionBar"; import { ImageContextMenu } from './ImageContextMenu'
import { Tooltip } from "./Tooltip"; import { GalleryEmptyState, GalleryLoadingState } from './gallery/GalleryEmptyState'
import { ImageTile } from './gallery/ImageTile'
const GAP = 6; 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 (
<div
data-gallery-context-menu
className="fixed z-40 min-w-52 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={() => { openImage(image); onClose(); }}
>
Open Preview
</button>
<button
className="w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-white/5 hover:text-white transition-colors"
onClick={async () => { await updateImageDetails(image.id, { favorite: !image.favorite }); onClose(); }}
>
{image.favorite ? "Remove Favorite" : "Add to Favorites"}
</button>
<button
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors ${
canFindSimilar
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={() => {
if (!canFindSimilar) return;
findSimilar(image.id, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
>
{canFindSimilar ? "Find Similar" : "Embeddings not ready"}
</button>
<div className="my-1 h-px bg-white/[0.06]" />
<div className="px-3 py-1 text-[10px] uppercase tracking-[0.18em] text-gray-600">Rating</div>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => { await updateImageDetails(image.id, { rating }); onClose(); }}
title={`Set ${rating} star rating`}
>
<svg
className={`h-4 w-4 ${rating <= image.rating ? "text-amber-300" : "text-white/20 hover:text-white/40"}`}
fill="currentColor" viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
);
})}
{image.rating > 0 ? (
<button
className="ml-1 rounded-md p-1 text-gray-600 hover:bg-white/5 hover:text-gray-300 transition-colors"
onClick={async () => { await updateImageDetails(image.id, { rating: 0 }); onClose(); }}
title="Remove rating"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
) : null}
</div>
</div>
);
}
export function ImageTile({
image,
onClick,
onContextMenu,
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => 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 = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<Tooltip label={image.filename} delay={500} block followCursor>
<div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
}`}
style={{ width: "100%", aspectRatio: "1 / 1" }}
onContextMenu={onContextMenu}
>
{/* 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. */}
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
}}
onDoubleClick={(event) => {
event.stopPropagation();
onClick();
}}
/>
{/* Selection corner a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */}
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? "border-blue-400 bg-blue-500 text-white opacity-100"
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
}`}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
{/* Image / placeholder */}
{src && !errored ? (
<>
{!loaded && <div className="absolute inset-0 animate-pulse bg-white/[0.04]" />}
<img
src={src}
alt={image.filename}
className={`h-full w-full object-cover transition-all duration-300 ${
loaded ? "opacity-100 scale-100" : "opacity-0 scale-[1.02]"
} group-hover:scale-[1.03]`}
loading="lazy"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
/>
</>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03] text-white/20">
{image.media_kind === "video" ? (
<svg className="h-7 w-7" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg className="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
</div>
)}
{/* Video play icon — subtle at rest, visible on hover */}
{image.media_kind === "video" && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="rounded-full bg-black/40 p-3 text-white backdrop-blur-sm opacity-50 group-hover:opacity-90 transition-opacity duration-200">
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
</div>
)}
{/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && (
<div
className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm"
title={image.embedding_error ?? "Embedding failed"}
>
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
)}
{image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M3.172 5.172a4 4 0 015.656 0L10 6.343l1.172-1.171a4 4 0 115.656 5.656L10 17.657l-6.828-6.829a4 4 0 010-5.656z" />
</svg>
</div>
)}
{image.rating > 0 && (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
{image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)}
</div>
)}
</div>
{/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
{/* Hover info — appears with overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
<p className="truncate text-[12px] font-medium text-white leading-tight">{image.filename}</p>
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
<div className="flex items-center gap-0.5">
{Array.from({ length: image.rating }, (_, i) => (
<svg key={i} className="h-2.5 w-2.5 text-amber-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
) : (
<span />
)}
<button
className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed"
}`}
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
findSimilar(image.id, image.folder_id);
}}
disabled={!canFindSimilar}
>
Similar
</button>
</div>
</div>
</div>
</Tooltip>
);
}
export function Gallery() { export function Gallery() {
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images)
const loadMoreImages = useGalleryStore((state) => state.loadMoreImages); const loadMoreImages = useGalleryStore((state) => state.loadMoreImages)
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage)
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages)
const loadingImages = useGalleryStore((state) => state.loadingImages); const loadingImages = useGalleryStore((state) => state.loadingImages)
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset)
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search)
const collectionTitle = useGalleryStore((state) => state.collectionTitle); const collectionTitle = useGalleryStore((state) => state.collectionTitle)
const imageLoadError = useGalleryStore((state) => state.imageLoadError); const imageLoadError = useGalleryStore((state) => state.imageLoadError)
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey); const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey)
const isSimilarResults = collectionTitle === "Similar Images"; const isSimilarResults = collectionTitle === 'Similar Images'
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search)
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{
x: number
y: number
image: ImageRecord
} | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
setContainerWidth(el.clientWidth); setContainerWidth(el.clientWidth)
const ro = new ResizeObserver((entries) => { const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width); setContainerWidth(entries[0].contentRect.width)
}); })
ro.observe(el); ro.observe(el)
return () => ro.disconnect(); return () => ro.disconnect()
}, []); }, [])
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset)
const cols = useMemo( const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize], [containerWidth, tileSize]
); )
const rowCount = Math.ceil(images.length / cols); const rowCount = Math.ceil(images.length / cols)
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]); const estimateSize = useCallback(() => tileSize + GAP, [tileSize])
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rowCount, count: rowCount,
@@ -338,165 +56,105 @@ export function Gallery() {
estimateSize, estimateSize,
overscan: 3, overscan: 3,
paddingStart: GAP, paddingStart: GAP,
}); })
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure()
}, [cols, virtualizer]); }, [cols, virtualizer])
useEffect(() => { useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 }); parentRef.current?.scrollTo({ top: 0, left: 0 })
}, [galleryScrollResetKey]); }, [galleryScrollResetKey])
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
if (el.scrollTop < 24) return; if (el.scrollTop < 24) return
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages()
} }
}, [images.length, loadMoreImages, loadingImages, totalImages]); }, [images.length, loadMoreImages, loadingImages, totalImages])
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current
if (!el) return; if (!el) return
el.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener('scroll', handleScroll, { passive: true })
return () => el.removeEventListener("scroll", handleScroll); return () => el.removeEventListener('scroll', handleScroll)
}, [handleScroll]); }, [handleScroll])
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return ( return (
<div className="relative flex-1 min-h-0"> <div className="relative min-h-0 flex-1">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950"> <div
{images.length === 0 && loadingImages ? ( ref={parentRef}
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0"> className="absolute inset-0 overflow-x-hidden overflow-y-auto bg-gray-950"
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72"> >
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> {images.length === 0 && loadingImages ? (
<p className="mt-4 text-sm text-white/40 font-medium"> <GalleryLoadingState isSimilarResults={isSimilarResults} parsedSearch={parsedSearch} />
{isSimilarResults ) : images.length === 0 && !loadingImages ? (
? "Finding similar images" <GalleryEmptyState
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 imageLoadError={imageLoadError}
? `Searching for matches to "${parsedSearch.query}"` isSimilarResults={isSimilarResults}
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 parsedSearch={parsedSearch}
? `Searching tags for "${parsedSearch.query}"` />
: "Loading media"} ) : (
</p> <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
<p className="text-xs text-white/20 mt-1"> {virtualizer.getVirtualItems().map((virtualRow) => {
{isSimilarResults const startIndex = virtualRow.index * cols
? "Comparing visual embeddings" const rowImages = images.slice(startIndex, startIndex + cols)
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0 return (
? "Semantic search can take a little longer than filename search" <div
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0 key={virtualRow.key}
? "Matching against AI and user tags" style={{
: "Fetching results"} position: 'absolute',
</p> top: virtualRow.start,
width: '100%',
height: virtualRow.size,
display: 'grid',
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: 'border-box',
}}
>
{rowImages.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, image })
}}
/>
))}
</div>
)
})}
</div> </div>
</div> )}
) : images.length === 0 && !loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0"> {images.length > 0 && loadingImages ? (
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8"> <div className="flex justify-center py-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/20 border-t-white/60" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30 font-medium">
{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"}
</p>
<p className="text-xs text-white/15 mt-1">
{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"}
</p>
</div> </div>
</div> ) : null}
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * cols;
const rowImages = images.slice(startIndex, startIndex + cols);
return (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: virtualRow.start,
width: "100%",
height: virtualRow.size,
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}}
>
{rowImages.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
);
})}
</div>
)}
{images.length > 0 && loadingImages ? ( {contextMenu ? (
<div className="flex justify-center py-8"> <ImageContextMenu
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> x={contextMenu.x}
</div> y={contextMenu.y}
) : null} image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</div>
{contextMenu ? ( {/* Pinned to the bottom of the gallery viewport outside the scroll
<ContextMenu container so it stays put while the grid scrolls. */}
x={contextMenu.x} <BulkActionBar />
y={contextMenu.y}
image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</div> </div>
)
{/* Pinned to the bottom of the gallery viewport outside the scroll
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
} }
+88
View File
@@ -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 (
<ContextMenu x={x} y={y} onClose={onClose}>
<MenuItem label="Open Preview" onSelect={() => openImage(image)} />
<MenuItem
label={image.favorite ? 'Remove Favorite' : 'Add to Favorites'}
onSelect={() => void updateImageDetails(image.id, { favorite: !image.favorite })}
/>
<MenuItem
label={canFindSimilar ? 'Find Similar' : 'Embeddings not ready'}
disabled={!canFindSimilar}
onSelect={() => findSimilar(image.id, image.folder_id)}
/>
<SubMenu label="Add to Album" panelClassName="max-h-64 overflow-y-auto">
{albums.length === 0 ? (
<MenuItem label="No albums yet" disabled />
) : (
albums.map((album) => (
<MenuItem
key={album.id}
label={album.name}
hint={album.image_count.toLocaleString()}
onSelect={() => void addToAlbum(album.id, [image.id])}
/>
))
)}
</SubMenu>
<MenuSeparator />
<MenuLabel>Rating</MenuLabel>
<div className="flex items-center gap-0.5 px-2 pb-1.5">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<button
className="rounded-md p-1 transition-colors hover:bg-white/5"
onClick={async () => {
await updateImageDetails(image.id, { rating })
onClose()
}}
>
<StarIcon
className={`h-4 w-4 ${rating <= image.rating ? 'text-amber-300' : 'text-white/20 hover:text-white/40'}`}
/>
</button>
</Tooltip>
)
})}
{image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-1 rounded-md p-1 text-gray-600 transition-colors hover:bg-white/5 hover:text-gray-300"
onClick={async () => {
await updateImageDetails(image.id, { rating: 0 })
onClose()
}}
>
<CloseIcon className="h-3 w-3" />
</button>
</Tooltip>
) : null}
</div>
</ContextMenu>
)
}
+28
View File
@@ -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 (
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()}>
<button
className="rounded bg-red-500/20 px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/30 hover:text-red-300"
onClick={onConfirm}
>
Confirm
</button>
<button
className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-gray-500 transition-colors hover:bg-white/10 hover:text-gray-300"
onClick={onCancel}
>
Cancel
</button>
</div>
)
}
+50
View File
@@ -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> | void
onClose: () => void
}) {
const [value, setValue] = useState(name)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, [])
const commit = async () => {
const trimmed = value.trim()
if (trimmed && trimmed !== name) {
await onRename(trimmed)
}
onClose()
}
return (
<input
ref={inputRef}
className="w-full rounded bg-white/10 px-1 py-0 text-[13px] leading-tight font-medium text-white ring-1 ring-blue-500/60 outline-none"
value={value}
onChange={(event) => 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()}
/>
)
}
File diff suppressed because it is too large Load Diff
-184
View File
@@ -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 (
<button
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
active ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
{label}
</button>
);
}
function MenuPanel({ children }: { children: React.ReactNode }) {
return (
<div className="absolute left-0 top-full z-30 mt-2 min-w-56 rounded-xl border border-white/10 bg-gray-950/95 p-2 shadow-2xl backdrop-blur">
{children}
</div>
);
}
function MenuItem({
label,
hint,
active = false,
onClick,
}: {
label: string;
hint?: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-blue-500/15 text-white" : "text-gray-300 hover:bg-white/5 hover:text-white"
}`}
onClick={onClick}
>
<span>{label}</span>
{hint ? <span className="text-xs text-gray-500">{hint}</span> : null}
</button>
);
}
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<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(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 (
<div ref={rootRef} className="relative z-20 flex items-center gap-1 border-b border-white/5 bg-gray-950/90 px-4 py-2 backdrop-blur">
<div className="relative">
<MenuButton
label="Library"
active={openMenu === "library"}
onClick={() => setOpenMenu((current) => (current === "library" ? null : "library"))}
/>
{openMenu === "library" ? (
<MenuPanel>
<MenuItem label="Add Folder" hint="Ctrl+O soon" onClick={handleAddFolder} />
<MenuItem
label="Re-index Current Folder"
hint={selectedFolderId === null ? "Select folder" : undefined}
onClick={handleReindex}
active={selectedFolderId !== null}
/>
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="View"
active={openMenu === "view"}
onClick={() => setOpenMenu((current) => (current === "view" ? null : "view"))}
/>
{openMenu === "view" ? (
<MenuPanel>
{ZOOM_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={zoomPreset === option.value}
onClick={() => {
setZoomPreset(option.value);
setOpenMenu(null);
}}
/>
))}
</MenuPanel>
) : null}
</div>
<div className="relative">
<MenuButton
label="Filter"
active={openMenu === "filter"}
onClick={() => setOpenMenu((current) => (current === "filter" ? null : "filter"))}
/>
{openMenu === "filter" ? (
<MenuPanel>
{FILTER_OPTIONS.map((option) => (
<MenuItem
key={option.value}
label={option.label}
active={mediaFilter === option.value}
onClick={() => {
setMediaFilter(option.value);
setOpenMenu(null);
}}
/>
))}
<div className="my-2 h-px bg-white/5" />
<MenuItem
label={favoritesOnly ? "Hide Favorites Only" : "Show Favorites Only"}
active={favoritesOnly}
onClick={() => {
setFavoritesOnly(!favoritesOnly);
setOpenMenu(null);
}}
/>
</MenuPanel>
) : null}
</div>
</div>
);
}
+7 -5
View File
@@ -6,14 +6,14 @@
// //
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point — // Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// used in the titlebar as the "update available" indicator. // 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({ export function PhokusMark({
className, className,
dotClassName, dotClassName,
}: { }: {
className?: string; className?: string
dotClassName?: string; dotClassName?: string
}) { }) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" className={className}> <svg viewBox="0 0 24 24" fill="none" className={className}>
@@ -33,7 +33,9 @@ export function PhokusMark({
<path d={BLADE} transform="rotate(240)" /> <path d={BLADE} transform="rotate(240)" />
<path d={BLADE} transform="rotate(300)" /> <path d={BLADE} transform="rotate(300)" />
</g> </g>
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null} {dotClassName ? (
<circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} />
) : null}
</svg> </svg>
); )
} }
File diff suppressed because it is too large Load Diff
+49 -1114
View File
File diff suppressed because it is too large Load Diff
-629
View File
@@ -1,629 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { Tooltip } from "./Tooltip";
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: TagCloudEntry;
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: TagCloudEntry[], 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; // ~92250px 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 = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
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 (
<motion.button
className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={
animated
? {
opacity: 1,
scale: 1,
x: [0, node.driftX * 0.65, 0],
y: [0, node.driftY * 0.65, 0],
rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
}
: { opacity: 1, scale: 1, rotate: node.rotateSeed }
}
transition={
animated
? {
opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
}
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
}
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
>
{src ? (
<img
src={src}
alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
<div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="flex items-end justify-between gap-2">
<div>
<p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div>
<span
className="explore-cluster-open rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
>
Open
</span>
</div>
</div>
</motion.button>
);
}
// Actual tag cloud — word size driven by log-scaled frequency
function TagWord({
entry,
index,
logMin,
logRange,
onSearch,
}: {
entry: ExploreTagEntry;
index: number;
logMin: number;
logRange: number;
onSearch: (tag: string) => void;
}) {
const theme = useGalleryStore((state) => state.theme);
const isLight = theme === "subtle-light";
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px
const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7;
// Faint low-frequency words read fine as subtle white-on-dark, but the same low
// opacity is unreadable on the light theme's cream, so raise the floor there.
const minOpacity = isLight ? 0.6 : 0.4;
return (
<Tooltip
label={`${entry.tag}${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
followCursor
delay={250}
>
<motion.button
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
>
<span
className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
>
{entry.tag}
</span>
<span
className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
style={{ backgroundColor: `${accent}22`, color: accent }}
>
{entry.count.toLocaleString()}
</span>
</motion.button>
</Tooltip>
);
}
function Spinner() {
return (
<motion.div
className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/>
);
}
// Separate component so its useLayoutEffect fires when the canvas is actually
// mounted — not at TagCloud mount time when the container may still be hidden
// behind a loading state.
function ClusterCloud({
entries,
onOpen,
}: {
entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void;
}) {
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(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 (
<div ref={canvasRef} className="relative isolate min-h-0 flex-1 overflow-hidden">
<div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => (
<CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
node={node}
onOpen={onOpen}
animated={!reducedMotion && node.index < 12}
/>
))}
</div>
);
}
// A flat, manageable row for a single tag — rename (which doubles as merge when
// the new name already exists) and delete across the whole library.
function TagManageRow({
entry,
onSearch,
onRename,
onDelete,
}: {
entry: ExploreTagEntry;
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.tag);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(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);
}
};
return (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]">
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setEditing(false);
}}
disabled={busy}
/>
) : (
<button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)}
title="Search this tag"
>
{entry.tag}
</button>
)}
</div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()}
disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
>
Save
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : confirming ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy}
>
Delete
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)}
title="Rename or merge into another tag"
>
Rename
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)}
>
Delete
</button>
</div>
)}
</div>
);
}
function TagManageList({
entries,
onSearch,
onRename,
onDelete,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30">
Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete
removes a tag from every image. These changes apply across your whole library.
</p>
<div className="divide-y divide-white/[0.05]">
{entries.map((entry) => (
<TagManageRow
key={entry.tag}
entry={entry}
onSearch={onSearch}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries);
const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading);
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
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 selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const [manageTags, setManageTags] = useState(false);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => {
if (exploreMode === "visual") void loadTagCloud();
else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
const { logMin, logRange } = useMemo(() => {
if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1)));
const lo = Math.min(...logs);
const hi = Math.max(...logs);
return { logMin: lo, logRange: hi - lo || 1 };
}, [exploreTagEntries]);
const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading;
const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0;
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return (
<div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header `relative z-10` keeps the folder-scope dropdown above the
cluster canvas, whose cards use a high z-index of their own. */}
<div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
{loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
: hasEntries
? exploreMode === "visual"
? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
: `${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"}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{exploreMode === "tags" && hasEntries ? (
<button
className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
}`}
onClick={() => setManageTags((v) => !v)}
>
{manageTags ? "Done" : "Manage"}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("visual")}
>
Clusters
</button>
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`}
onClick={() => setExploreMode("tags")}
>
Tag Cloud
</button>
</div>
</div>
</div>
</div>
{loading ? (
<div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div>
) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8">
<p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
{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."}
</p>
</div>
) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : manageTags ? (
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8">
<div className="flex flex-wrap items-center justify-center gap-x-0.5 gap-y-1 leading-none">
{exploreTagEntries.map((entry, index) => (
<TagWord
key={entry.tag}
entry={entry}
index={index}
logMin={logMin}
logRange={logRange}
onSearch={searchForTag}
/>
))}
</div>
</div>
)}
</div>
);
}
-106
View File
@@ -1,106 +0,0 @@
import { useEffect, useRef, useState } from "react";
export interface DropdownOption {
value: string;
label: string;
}
export function ThemedDropdown({
value,
options,
onChange,
ariaLabel,
compact = false,
align = "right",
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
ariaLabel: string;
compact?: boolean;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs light-theme:border-gray-700/50 light-theme:bg-gray-900"
} ${open ? "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white" : "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More