90 Commits

Author SHA1 Message Date
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
LyAhn d55e4c7502 docs: changelog for unreleased fixes
github/actions/ci GitHub Actions CI finished: success
2026-06-28 21:13:12 +01:00
LyAhn a40a2e8d12 chore: split production build into CPU and CUDA scripts 2026-06-28 21:13:12 +01:00
LyAhn 2ce1547844 fix: improve Explore cluster layout and light-theme readability 2026-06-28 21:12:26 +01:00
LyAhn 623aabbb51 fix: surface actively-processing folder over paused one in background tasks bar 2026-06-28 21:12:24 +01:00
LyAhn f65fd350cc fix: make toolbar, sidebar, and lightbox responsive on small screens 2026-06-28 21:12:22 +01:00
LyAhn 86a1a53289 fix: clamp window to monitor work area so it fits small screens on first launch 2026-06-28 21:12:21 +01:00
LyAhn ebf16e8cb9 fix: skip Phokus's own app-data dir when indexing to break self-indexing loop 2026-06-28 21:12:21 +01:00
LyAhn e7d9c39fd1 docs: regroup unreleased changelog Added entries
github/actions/ci GitHub Actions CI finished: success
2026-06-28 14:56:00 +01:00
LyAhn 136d74a81b Merge feat/smart-albums-multiselect: albums, multi-select, EXIF, tag management, color search
- Manual albums (create/rename/delete/reorder; multi-select bulk add/remove) in a distinct sidebar section
- Gallery multi-select + bulk actions (tag, rate, favorite, add-to-album, delete with disk-delete confirmation)
- Lightbox EXIF/camera panel (on-demand) with a GPS map link
- Tag management in Explore (rename/merge/delete, library-wide)
- Album-scoped similar-image search
- Color search — filter the gallery by dominant color (swatches + custom picker), with background backfill
- Reusable tooltip component, duplicate-finder delete confirmation, CPU/CUDA build badge
- Post-review security/accessibility hardening (validated URL-open commands, etc.)
2026-06-28 14:45:31 +01:00
LyAhn bb0038e0a1 chore: post-review hardening + changelog link tooltip
Security / robustness / a11y polish on top of the color-search + tooltips work:

- URL opening: route through validated backend commands (open_map_location
  with lat/lon bounds-checking, open_changelog_url with a fixed URL) instead
  of the frontend opener:allow-open-url capability, which is now removed.
- EXIF GPS parsing: validate coordinate ranges and require the correct
  N/S/E/W hemisphere ref byte, then clamp.
- Guard double-submit on album create / add-to-album (Lightbox, BulkActionBar,
  Sidebar) and discard stale autocomplete responses in the bulk tag editor.
- Gallery tile: stop nesting <button>s — non-interactive tile div with an
  overlay button for open/toggle; checkbox and Similar promoted with z-index
  + focus rings.
- Accessibility: keyboard handlers, focus-visible rings, and aria on album
  rows, tag-manage actions, and tile controls; Tooltip uses a block <div>
  wrapper in block mode and aria-hidden when hidden.
- Show the destination URL in a tooltip on the "Full changelog" link so the
  user can see where it goes before clicking.
- Toolbar "All" filter also clears the color filter; color-filtered views no
  longer get unfiltered newly-indexed images injected.
- Sidebar reorder: bail if the album set changed mid-drag.
- Tooling: add cargo fmt scripts; alphabetize package.json scripts.
2026-06-28 14:39:21 +01:00
LyAhn 90dec3b212 feat: add color search and reusable tooltips
Add dominant-color palette extraction, storage, filtering, and startup backfill so the gallery and Timeline can be filtered from the toolbar color picker.

Introduce a reusable tooltip component and migrate the color filter, update indicator, and gallery filename hover affordances to it.
2026-06-28 11:04:33 +01:00
LyAhn e3fde46e91 feat: add album scope for similar image search
Similar search scoping:
- Add current_album as a similar-scope option and remember the source album when similar or region searches are launched from an album.
- Route gallery and lightbox similar actions through scope-aware store helpers so Album/Folder/All choices are applied consistently.
- Keep pagination and scope toggles working for both whole-image and region-search result sets.

Backend filtering:
- Extend find_similar_images and find_similar_by_region params with album_id, giving album scope precedence over folder scope.
- Add album_membership filtering for HNSW whole-image search and brute-force crop embedding search.
2026-06-28 01:21:31 +01:00
LyAhn a12e81d8bd feat: lightbox EXIF panel, tag management, reorderable albums
EXIF info panel:
- New on-demand get_image_exif command (kamadak-exif) returning camera/lens/
  aperture/shutter/ISO/focal-length and decimal GPS; read from the file when the
  lightbox opens (no DB schema change, works on already-indexed images).
- Lightbox shows a Camera panel; GPS opens the location in the browser via
  OpenStreetMap (adds opener:allow-open-url capability).

Tag management:
- Backend rename_tag (rename, or merge when the target exists) and delete_tag
  (library-wide), both clearing the tag-cloud cache; store actions invalidate
  tag caches, refresh Explore, and re-point/refresh an active tag-search.
- Explore -> Tag Cloud gains a Manage mode: a flat list with per-tag rename/
  merge/delete.

Albums:
- Drag-to-reorder in the sidebar via framer-motion Reorder with a hover handle;
  order persists through the existing reorder_albums command.

Reads live store order on drag end and robustly derives GPS hemisphere from raw
EXIF ref bytes (review follow-ups). CHANGELOG updated.
2026-06-27 23:50:44 +01:00
LyAhn 6bef90b7fb feat: manual albums + gallery multi-select with bulk actions
Albums (manual collections):
- New albums/album_images tables with FK cascades; DB functions and Tauri
  commands for create/rename/delete/delete-many/reorder/list, add/remove
  images, and paginated get_album_images.
- Distinct sidebar "ALBUMS" section with cover thumbnails, create/rename/
  delete, and a Manage multi-select mode for bulk album deletion.
- Album view reuses the gallery grid (activeView "album" + selectedAlbumId);
  spans folders; add from the bulk bar or the lightbox, remove from within.

Gallery multi-select + bulk actions:
- gallerySelectedIds selection model with a top-left corner checkbox that
  reveals on corner hover; click-to-toggle in selection mode, double-click
  to open.
- Floating BulkActionBar: tag (inline autocomplete popover), rating,
  favorite, add-to-album, and a delete with an explicit "from disk"
  confirmation. Batch commands bulk_update_details/bulk_add_tags/
  bulk_remove_tag.

Also:
- Duplicate Finder delete now requires confirmation with clear "from disk"
  wording (was single-click fire-and-forget).
- CPU/CUDA build-variant badge in Settings (get_build_variant).
- Rating/favorite no longer re-sorts derived collections (similar/region/
  semantic/tag/album results); single and bulk paths replace in place there.
- Album-aware indexed-images/media-updated handlers so thumbnails paint and
  newly-indexed files don't leak into an album view.
- CHANGELOG updated.
2026-06-27 15:23:54 +01:00
LyAhn 55cd3b5aa7 Merge feat/whats-new-updater-ux: What's New screen + updater UX + light-theme fixes
github/actions/ci GitHub Actions CI finished: success
Post-0.1.1 polish queued for the next release (0.1.2):

- What's New: post-update toast that opens an in-app, offline release-notes
  screen (collapsible Added/Changed/Fixed sections) sourced from the bundled
  CHANGELOG.md, reopenable from Settings -> Updates. Backed by a
  last_seen_version settings file that tells upgrades from fresh installs.
- Updater: the progress toast reappears when an update is started from the
  title-bar indicator or Settings after the prompt was dismissed; Settings
  gains a real download progress bar with a percentage.
- Light theme: fix the recurring subtle-light breakage — neutral surfaces rely
  on the CSS-variable remap instead of light-theme:bg-white, and the green
  action buttons drop the broken light-theme:hover:bg-emerald-200 for an
  override-free emerald tint that auto-themes (updater, What's New, onboarding).
- Debug panel: What's New triggers.
2026-06-23 21:36:35 +01:00
LyAhn c878970180 feat: What's New screen + updater progress, plus light-theme fixes
Post-update UX for the next release (0.1.2):

- What's New: after a version change, greet the user with a toast that opens
  an in-app release-notes screen (collapsible Added/Changed/Fixed sections)
  sourced from the bundled CHANGELOG.md, reopenable from Settings -> Updates.
  Backed by a last_seen_version settings file (get/set_last_seen_version
  commands) that distinguishes upgrades from fresh installs.
- Updater: the download/install progress toast now reappears when an update is
  started from the title-bar indicator or Settings after the prompt was
  dismissed; Settings -> Updates gains a real progress bar with a percentage.
- Light theme: fix the recurring subtle-light breakage. Neutral surfaces now
  rely on the CSS-variable remap instead of light-theme:bg-white (which forced
  surfaces dark because --color-white is remapped dark); the green action
  buttons drop the broken light-theme:hover:bg-emerald-200 (remapped dark,
  unreadable on hover) for an override-free emerald-500 tint that auto-themes,
  across the updater, What's New, and onboarding.
- Debug panel: add What's New triggers (toast / modal / reset).
2026-06-23 21:33:08 +01:00
LyAhn 1a95e31f78 docs: fill in 0.1.1 release checksums
Add the SHA-256s for the published CPU and CUDA installers to the
0.1.1 release-notes draft.
2026-06-23 20:26:25 +01:00
LyAhn e1e89b0f87 chore(release): 0.1.1
github/actions/ci GitHub Actions CI finished: success
github/actions/release GitHub Actions release finished: success
Bump version to 0.1.1 (package.json, Cargo.toml, Cargo.lock), date the
changelog section (2026-06-23), and add the GitHub release-notes draft.

QoL release on top of 0.1.0: custom multi-folder picker, theme system,
timeline scrubber, folder reordering, gallery/duplicate-finder
virtualisation, video playback settings, rebuild-semantic-index action,
plus AVIF thumbnail, video-embedding, and Subtle Light theme fixes.
2026-06-23 20:00:08 +01:00
LyAhn 0909b58110 docs: update changelog with QoL-02 and AVIF fixes 2026-06-23 08:55:08 +01:00
LyAhn 4f9ab0b821 fix: support AVIF thumbnail processing
github/actions/ci GitHub Actions CI finished: success
Route AVIF thumbnail generation through the bundled FFmpeg path instead of the Rust image decoder, avoiding unsupported-format failures without requiring system dav1d dependencies.

Requeue existing AVIF jobs that previously failed with unsupported-format errors and feed generated JPEG derivatives to embedding/tagging preprocessing while leaving lightbox display on the original AVIF file.
2026-06-22 20:43:46 +01:00
LyAhn a06e76c7a7 fix: resolve Rust Clippy CI failures
github/actions/ci GitHub Actions CI finished: success
Derive default implementations for captioner and tagger option enums, simplify sorting and progress multiple checks, and remove redundant iterator conversions.
2026-06-21 21:00:46 +01:00
LyAhn 1e008244ae fix(db): suppress too_many_arguments clippy lint on count_images
github/actions/ci GitHub Actions CI finished: failure
2026-06-21 19:40:35 +01:00
LyAhn ebed194f17 Merge feat/qol-02: QoL polish — folder picker, settings, duplicate finder
github/actions/ci GitHub Actions CI finished: failure
- Custom multi-folder picker replaces the native OS dialog: browse the
  filesystem in-app, stage multiple folders at once, and add them in one
  shot. Virtualised list handles large directory trees without jank.
- Duplicate Finder group list is now virtualised, keeping the UI
  responsive even with hundreds of duplicate groups.
- Settings panel reordered: General is now the first (default) section.
- Lightbox video playback toggles (autoplay / auto-mute) added to
  General settings, wired to the store with ARIA switch roles.
- "Rebuild Semantic Index" maintenance action added to Settings.
- Folder picker QoL fixes: chevron tooltip, Unix breadcrumb root label,
  and partial-failure staging panel cleanup.
2026-06-21 19:30:35 +01:00
LyAhn 3684b98d55 fix(folder-picker): address QoL issues from PR review
- Fix chevron tooltip: was "Open folder" in both branches; now shows
  "No subfolders" when the entry has no children (consistent with the
  existing opacity-45 visual cue on the same chevron icon)
- Fix Unix breadcrumb root label: was always "Home" even for non-home
  paths like /mnt/data — now labelled "/" which is always accurate
- Fix partial-failure staging: on a mixed add result, successfully-added
  entries are now removed from the staging panel so only genuinely failed
  folders remain for the user to retry (index-pairing is safe because the
  backend returns results in input order via a preserved .map())
2026-06-21 19:21:00 +01:00
LyAhn 74a4134f2f feat: add custom multi-folder picker
Replace native add-folder dialogs with an in-app folder picker that supports collecting folders from multiple locations before adding them together.

Add backend directory listing and batch add commands with duplicate skipping, plus store actions and a themed picker UI with a dedicated folders-to-add panel.
2026-06-21 17:38:01 +01:00
LyAhn f66fbe7931 feat(settings): add "Rebuild semantic index" maintenance action
Drops and recreates the sqlite-vec tables at the current model dimension, then
re-queues every image for embedding. Fixes "dimension mismatch" search errors
that occur when the vector table was built for a different model/dimension (e.g.
after experimenting with 768-dim models against this 512-dim build).

The rebuild runs under the embedding worker's DB write lock and resets the job
queue inside a single transaction, so it can't interleave with an in-flight
embedding batch (per code review).
2026-06-21 15:17:34 +01:00
LyAhn 5870205047 feat(settings): General-first layout + lightbox video playback toggles
Reorder the Settings sections so General is the top and default section instead
of AI Workspace. Add two persisted settings under a new "Video playback" group:
- Autoplay in lightbox (default on)
- Start muted (default off)

VideoPlayer reads these when a video opens — autoplaying only when enabled and
starting muted when enabled, otherwise falling back to the session's last-used
mute state. Settings apply to the next opened video, not the current one.
2026-06-21 14:39:41 +01:00
LyAhn 3db95a4489 perf: virtualize the Duplicate Finder group list
The duplicate view rendered every group and every thumbnail at once — a 5,000-pair
result mounted ~10K <img> elements, making scroll lag heavily (same class of bug
as the old per-month Timeline). Virtualize the group list so only on-screen cards
mount; heights are measured dynamically since each group wraps a variable number
of copies.
2026-06-21 13:45:11 +01:00
LyAhn c1ab651131 Merge: drop dead settings-modal theme CSS
github/actions/ci GitHub Actions CI finished: failure
Removes subtle-light .settings-modal overrides that targeted classes no component
on main applies (dead rules), keeping the Settings modal flat and themed via the
global accent variables.
2026-06-21 12:58:21 +01:00
LyAhn 166ffdb189 chore: remove dead .settings-modal theme overrides
These subtle-light overrides target classes (settings-modal, settings-nav-active,
settings-model-card) that no component on main applies, so the rules never took
effect. They also pulled the Settings modal toward a card look we don't want. The
accent-text readability is now handled globally by the theme's accent variables,
so the modal themes correctly (flat) without this block.
2026-06-21 12:58:19 +01:00
LyAhn 58750b169a Merge Subtle Light accent-text readability fix
Coloured text/icons (warnings, errors, status) used pastel accent shades tuned
for dark UIs and washed out on the light theme — e.g. "Update check failed" in
Settings was near-invisible. Darken those accent variables in the subtle-light
theme while resetting them to the bright originals inside .media-dark-surface,
so light chrome reads clearly and on-photo overlays keep their signal colours.
2026-06-21 12:53:44 +01:00
LyAhn 1e148bdf18 fix: make accent text readable in Subtle Light theme
Pastel accent shades (amber/red/rose/emerald/sky/violet/blue -200..-400) are
tuned for dark UIs and washed out on the light chrome — e.g. "Update check
failed" in the Settings updates row was near-invisible. Darken those accent
variables in the subtle-light theme so coloured text/icons stay readable, and
reset them to Tailwind's bright defaults inside .media-dark-surface so on-photo
overlays (ratings, failed badges, the lightbox region tool) keep their signal
colours. Remapping the variable rather than the utility also covers opacity
variants like text-amber-300/90.
2026-06-21 12:34:04 +01:00
LyAhn 7367845f8b Merge 0.1.1 QoL fixes
github/actions/ci GitHub Actions CI finished: failure
Post-release quality-of-life work for 0.1.1:

- Timeline: right-edge year/month scrubber, full-library load so the scrubber
  spans everything, and per-row virtualization so dense months scroll smoothly.
- Gallery grid row-virtualization; folder reordering (drag + keyboard) with
  persisted custom order and A-Z/Z-A/Custom sort.
- Theme system (Phokus, Subtle Light, Conventional Dark) with an onboarding
  theme picker, plus subtle-light parity fixes for the lightbox panel, media
  overlays, duplicate finder, and window controls.
- Failed AI-tagging locate/filter from the background worker prompt.
- Perf: background media-updated batches no longer re-sort the whole image set.
- Video embedding jobs no longer churn through false failures before their
  thumbnail exists.
- Tooling: changelog helper + notes.
2026-06-21 08:48:53 +01:00
LyAhn 50e8bc8e4d docs: record 0.1.1 QoL fixes in changelog 2026-06-21 08:48:20 +01:00
LyAhn 779a18f56e fix: use valid end-of-input anchor in changelog-add
JS regex has no \z anchor — it matched a literal 'z', so appending to the last
section under [Unreleased] silently failed and duplicated the heading. Use
$(?![\s\S]) to anchor to true end-of-input.
2026-06-21 08:48:19 +01:00
LyAhn d027de675b fix: debounce folder keyboard-reorder persistence
Holding Up/Down on a folder's drag handle fired a reorder_folders DB write per
keystroke. Update the local order immediately for responsiveness, but debounce
the persist (400ms) with cleanup on unmount.
2026-06-21 08:48:18 +01:00
LyAhn b7cfc9177e fix: subtle-light theme parity for lightbox panel and media surfaces
In subtle-light, the lightbox dragged its whole surface (including the metadata
panel) dark via media-dark-surface, while conventional-dark themed the panel
normally. Scope the dark surface to the media canvas and re-light the panel by
remapping --color-* variables on a .lightbox-panel wrapper (Tailwind v4 resolves
every colour utility through these vars, so this re-themes the subtree —
including accents — with no !important). Mark gallery/duplicate media tiles as
media-dark-surface so their on-image overlays stay light-on-dark, and theme the
window restore icon via var(--color-gray-950) instead of a hardcoded hex.
2026-06-21 08:48:05 +01:00
LyAhn 479de76ebb perf: stop full re-sort on media-updated batches
replaceExistingImages re-sorted the entire loaded image array on every
media-updated event. Harmless for the ~200-item gallery window, but in Timeline
(which loads the whole library) it was an O(n log n) pass many times per second
during background indexing — severe lag, occasional crashes. Thumbnail/metadata
fills don't change list position (Timeline re-buckets by taken_at separately), so
replace records in place and skip the sort; return the same array reference when
nothing matched to avoid a wasted re-render.
2026-06-21 08:48:05 +01:00
LyAhn 21f6c30d25 perf: row-virtualize Timeline instead of per-month
Each month was a single virtual item that rendered all of its tiles, so
scrolling into a busy month mounted thousands of ImageTiles at once. Flatten
months into a fixed-height row list (header rows + tile rows of `cols` images),
mirroring the Gallery grid, so only on-screen rows render and thumbnails stream
in as you scroll. Active-month tracking and scrubber jump-to-month are remapped
to the flat row model.
2026-06-21 08:47:48 +01:00
LyAhn 1df75fd490 Refine explore and settings theming 2026-06-20 18:08:39 +01:00
LyAhn a4c928345c chore: add changelog helper and unreleased notes 2026-06-18 00:49:30 +01:00
LyAhn ca58c2ddd4 fix: add failed tag locate and filter controls
Add a failed-tag discovery flow for background worker failures.

Changes:
- Add a Failed Tags toolbar filter that appears when tag failures exist.
- Add Locate buttons for failed tag tasks in the background worker prompt.
- Route Locate to the affected folder and filter the gallery to images with tagger errors.
- Fetch and display failed tag filenames/errors in the expanded worker details.
- Add a backend query and gallery filter flag for images with failed AI tagging.
- Improve subtle-light contrast for failed worker chips, filenames, and Locate/Retry buttons.
- Also slightly increases the title bar update indicator pulse size for better visibility.
2026-06-18 00:36:02 +01:00
LyAhn c97fec2eb3 fix: improve light theme onboarding controls
Make the onboarding tour theme-aware across the app themes, add the first-run theme picker, and keep fake media previews on the dark media surface. Update light-mode secondary controls in onboarding, settings, dropdowns, toolbar controls, and duplicate actions so they no longer render as dark buttons on subtle-light.
2026-06-17 22:07:35 +01:00
LyAhn 9047c8053a feat: 0.1.1 — timeline scrubber, gallery virtualisation, folder reorder, QoL polish
Timeline:
- Add a right-edge scrubber (year labels + month dots) that jumps to any
  period; runs in the same direction as the scrolled content
- Load the full filtered set in Timeline view so the scrubber spans the whole
  library instead of just the first page

Gallery & UI:
- Virtualise the gallery grid
- Folder reordering in the sidebar (drag + persisted sort_order) and a themed
  dropdown component
- Subtle Light theme contrast fixes for toggles, secondary buttons and the
  update toast

Embedding workers now defer video jobs without a thumbnail at claim time and
requeue any previously-failed deferred jobs on startup, so videos no longer
churn through failed embeddings.

QoL polish across BackgroundTasks, DuplicateFinder, Lightbox and VideoPlayer.
2026-06-17 18:37:37 +01:00
LyAhn f049f8c997 Update ci.yml
flip paths-ignore to paths and point to src + src-tauri
2026-06-15 21:44:34 +01:00
LyAhn 3e0f59300e feat(website): launch phokus.jezz.wtf product site
github/actions/ci GitHub Actions CI finished: cancelled
Single-page marketing site built with React 19, Vite 7, and Tailwind v4.
Covers the full product story: local-first privacy, semantic search, explore/timeline,
curation, deduplication, and download. Screenshots transcoded to AVIF/WebP at build
(~5.3 MB masters → ~0.5 MB served). Self-hosted Inter and Space Grotesk variable fonts.
Mobile-first redesign with phone-optimised hero, swipeable feature cards, and touch-friendly
navigation. Includes the standalone Phokus aperture SVG asset.

CI: exclude website/ and docs/ from the Rust/Tauri check workflow.
2026-06-15 21:25:55 +01:00
LyAhn 00bf7da344 feat(website): redesign the mobile experience
Replace the stacked desktop layout with a phone-first product flow featuring an image-led hero, inline privacy proof, compact semantic search, swipeable feature cards with live pagination, expandable technical details, and touch-friendly navigation and calls to action. Add the standalone Phokus aperture SVG asset.
2026-06-15 21:15:18 +01:00
LyAhn e14dbda41d feat(website): finish responsive polish
Self-host the Inter and Space Grotesk variable fonts, tighten the mobile layout, hide oversized edge marks on narrow screens, and add keyboard focus plus reduced-motion safeguards. Exclude website and documentation-only changes from the desktop CI workflow.
2026-06-15 19:55:01 +01:00
LyAhn 072c3887cf feat(website): add Phokus product site with optimized media pipeline
Single-page marketing site for phokus.jezz.wtf in the website/ pnpm workspace
(React 19 + Vite 7 + Tailwind v4). Sections: Hero, Local-first, Search, Explore
& timeline, Curate, Cleanup, Tech facts, Download. Product-first composition with
a recurring aperture motif (EdgeMark) bleeding off alternating edges.

Screenshots are transcoded to AVIF/WebP at build via vite-imagetools (sharp);
~5.3MB of masters -> ~0.5MB served. Commits only the 7 in-use captures.
2026-06-15 19:39:56 +01:00
LyAhn 584a92b7cd ci: report GitHub Actions status to Gitea
github/actions/ci GitHub Actions CI finished: failure
2026-06-15 00:36:25 +01:00
LyAhn 6a5cf0afe3 docs(changelog): date the 0.1.0 release (2026-06-14)
CI / check (push) Has been cancelled
2026-06-14 20:28:22 +01:00
LyAhn ce804f5aa5 chore(gitignore): drop blanket *.json ignore, track configs directly
CI / check (push) Has been cancelled
The global *.json rule (added for transient ComfyUI workflow dumps) forced
git add -f on real configs and hid tauri.conf.json from tauri-action's
gitignore-aware globbing. Removed it and the negation workarounds; local-only
ignores (e.g. skills-lock.json) live in .git/info/exclude instead.
2026-06-14 20:10:18 +01:00
144 changed files with 13632 additions and 1396 deletions
+20
View File
@@ -0,0 +1,20 @@
# External CI
CI and release builds run on the GitHub mirror because they require Windows
runner capacity. The GitHub workflows report their state back to this Gitea
repository through the commit status API.
Keep this directory in the repository. Gitea checks `.gitea/workflows` before
falling back to `.github/workflows`; an existing directory with no workflow
files prevents the GitHub-only workflows from being queued by Gitea Actions.
## Setup
1. In Gitea, create an access token with `write:repository` permission for an
account that can update `JezzWTF/phokus`.
2. In the GitHub repository, add that token as the Actions repository secret
`GITEA_STATUS_TOKEN`.
The status steps are non-blocking. If Gitea is temporarily unavailable, the
GitHub build result is preserved and the failed status update remains visible
in the workflow log.
+62
View File
@@ -3,7 +3,13 @@ name: CI
on:
push:
branches: [main]
paths:
- 'src/**'
- 'src-tauri/**'
pull_request:
paths:
- 'src/**'
- 'src-tauri/**'
workflow_dispatch:
concurrency:
@@ -15,7 +21,33 @@ jobs:
# windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/ci"
description = "GitHub Actions CI is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -52,3 +84,33 @@ jobs:
- name: Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- name: Report final status to Gitea
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/ci"
description = "GitHub Actions CI finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
+56
View File
@@ -14,7 +14,33 @@ jobs:
permissions:
contents: write
runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps:
- name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/release"
description = "GitHub Actions release is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
@@ -55,3 +81,33 @@ jobs:
# Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features'
- name: Report final status to Gitea
if: always() && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/release"
description = "GitHub Actions release finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
+7 -9
View File
@@ -32,17 +32,15 @@ dist-ssr
# Misc
*.py
*.json
*.pyc
# 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/
# Keep the Tauri configs tracked despite the *.json rule above. tauri.conf.json
# must also be un-ignored so tauri-action can find it: it globs for the config
# honoring .gitignore, and an ignored config makes the release build fail with
# "Failed to resolve Tauri path".
!src-tauri/tauri.conf.json
!src-tauri/tauri.cuda.conf.json
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+187 -1
View File
@@ -5,7 +5,192 @@ 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)
(0.x: anything may change between minor versions).
## [0.1.0] — Unreleased
## [Unreleased]
### Added
- **What's New** — after updating, Phokus now greets you with a "What's new"
toast that opens a tidy in-app tour of the new version. Added, Changed, and
Fixed notes are grouped into collapsible sections, so you can skim the good
bits without playing "spot the difference".
- **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. There is also an "Open tag
manager" shortcut in Settings -> AI Workspace when you want to get straight to
the cleanup.
- **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).
Models download only when needed, and tags remember which model created them.
- **Related tags in Explore** — Hover over a tag in the Tag Cloud to see the tags
that most often appear with it, complete with connection lines and image counts.
Handy for finding little clusters you did not know were there.
- **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.
### Changed
- **Neater lightbox details** — image and video metadata now sits in two columns,
so the info panel shows more at a glance with less scrolling.
- **Faster Explore revisits** — returning to a folder's visual clusters should
feel much faster now, even in big libraries.
- **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
- **Explore no longer flashes the last folder** — switching folders now clears
the old clusters/tags and shows a loading state while the new folder catches
up.
- **Ratings keep your search order** — rating or favoriting an image no longer
reshuffles similar-image, region, semantic, tag, or album results.
- **Update progress comes back when you need it** — if you dismiss the update
prompt and later start the update from the title bar or Settings, the progress
toast now reappears instead of hiding away in Settings.
- **Subtle Light cleanup** — fixed dark or hard-to-read surfaces, hover states,
dialogs, updater buttons, onboarding controls, and green action buttons in the
light theme.
- **No more self-indexing loops** — if you add a broad folder like your user
profile, Phokus now skips its own app-data directory instead of indexing its
thumbnail cache forever.
- **Background tasks show the active work first** — when one folder is paused and
another is processing, the active folder gets the main spot in the background
tasks bar.
- **First launch fits smaller screens** — fresh installs now clamp the window to
the usable monitor area, so 1366x768-style displays do not lose part of the app
below the taskbar.
- **Explore is clearer in Subtle Light** — cluster captions, buttons, cloud
words, hover glows, and the new connection lines now use stronger light-theme
colours.
- **Explore got a few sharp edges sanded down** — Cluster Cloud uses the in-app
tooltip, singular counts now say "1 image", and the folder-scope dropdown no
longer hides behind cluster cards.
- **AI tagging stays responsive** — GPU tagging now works in smaller bursts with
brief pauses between them, so the UI keeps moving and the first batch starts
sooner.
- **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.
## [0.1.1] — 2026-06-23
### Added
- **Custom multi-folder picker** — replaces the native OS dialog with an
in-app folder browser that lets you navigate, stage folders from multiple
locations, and add them all in one go. Duplicate roots are skipped
automatically; partially-failed batches remove successfully-added entries
from the staging panel so only failed folders remain to retry.
- **Rebuild semantic index** maintenance action in Settings — drops and
recreates the vector tables at the current model dimension, then re-queues
every image for embedding. Fixes "dimension mismatch" search errors that
occur after switching between CLIP models with different output sizes.
- **Video playback settings** — new Video Playback group in Settings with two
persisted toggles: "Autoplay in lightbox" (default on) and "Start muted"
(default off). Settings apply to the next opened video rather than the
current one.
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
any period in the library. Timeline now loads the full filtered set so the
scrubber spans the whole library instead of just the first page.
- **Folder reordering** in the sidebar — drag-and-drop (with edge auto-scroll)
or keyboard (↑/↓ on the drag handle), with the custom order persisted across
sessions; the Libraries list also gains AZ / ZA / Custom sort.
- Failed AI-tagging jobs can now be located from the background worker prompt,
including a gallery filter for images with failed tags and an expanded list
of failed filenames/errors.
- A new theme system adds Phokus, Subtle Light, and Conventional Dark chrome
options across the app.
- First-run onboarding now includes an inline theme picker so new users can
choose their preferred app chrome before continuing the tour.
### Changed
- Settings sections are reordered — General is now the first and default
section instead of AI Workspace.
- The Duplicate Finder group list is now virtualised — only on-screen cards
mount, so large result sets (e.g. 5,000+ pairs) scroll without lag rather
than mounting every thumbnail at once.
- The gallery grid is now row-virtualised, so very large libraries scroll
smoothly and only on-screen thumbnails are rendered.
- Polished the new theme surfaces before release, including readable
subtle-light secondary buttons, failed-worker action buttons, and onboarding
controls.
- Onboarding preview media keeps the dark gallery/media surface regardless of
the active chrome theme.
### Fixed
- **AVIF thumbnails** — AVIF files are now processed correctly by routing
thumbnail generation through the bundled FFmpeg path instead of the Rust
image decoder (which has no dav1d dependency). Previously-failed AVIF jobs
are requeued on startup; JPEG derivatives are fed to the embedding and
tagging pipeline while the lightbox continues to display the original file.
- Accent text is now readable in the Subtle Light theme.
- Folder picker chevron tooltip now correctly shows "No subfolders" for leaf
entries instead of "Open folder" in both branches.
- Folder picker Unix breadcrumb root now shows "/" instead of always "Home"
for non-home paths such as `/mnt/data`.
- Video embedding jobs are no longer claimed before their thumbnail exists, and
any that previously failed for that reason are requeued on startup — videos no
longer churn through failed embeddings.
- Subtle Light theme consistency — the lightbox metadata panel now follows the
light chrome while the image canvas stays dark (matching Conventional Dark),
and gallery/timeline media badges, duplicate-finder thumbnails, and the window
restore icon now theme correctly instead of staying Phokus-dark.
- Timeline scrolling is now smooth on large libraries — it virtualizes per row
of tiles instead of per month, so a month with thousands of photos no longer
mounts every tile at once (thumbnails now load in incrementally as you scroll,
matching the All Media grid).
- Background worker updates (thumbnails, metadata, embeddings, tags) no longer
re-sort the entire loaded image set on every batch. In Timeline, which loads
the whole library, this re-sort caused severe lag and could crash the app
during background indexing.
## [0.1.0] — 2026-06-14
First public release. Windows desktop, distributed as an unsigned NSIS
installer with a built-in updater.
@@ -47,4 +232,5 @@ installer with a built-in updater.
Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling.
[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
+5 -2
View File
@@ -15,8 +15,11 @@ pnpm dev:app
# Frontend only (no Tauri window)
pnpm dev:vite
# Production build
pnpm build:app
# Production build (CPU)
pnpm build:app:cpu
# Production build (CUDA / GPU-accelerated)
pnpm build:app:cuda
# Type-check frontend
pnpm build:vite
+12 -3
View File
@@ -34,7 +34,7 @@ A local-first desktop media library for browsing, filtering, and curating image
| Images | Videos |
|--------|--------|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
| tiff, tif, webp, avif, heic, heif | webm |
| tiff, tif, webp, avif | webm |
## Installation
@@ -109,13 +109,22 @@ pnpm dev:app
# Frontend only
pnpm dev:vite
# Production build
pnpm build:app
# Browser-only UI Lab with mocked Tauri APIs
pnpm dev:ui
# Production build (CPU)
pnpm build:app:cpu
# Production build (CUDA / GPU-accelerated)
pnpm build:app:cuda
# Type-check the frontend
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
1. Add a folder from the sidebar — the Rust indexer walks it recursively.
+64
View File
@@ -0,0 +1,64 @@
# Phokus v0.1.1
> 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.
This is a quality-of-life release on top of 0.1.0: a new in-app folder
picker, themes, smoother scrolling on large libraries, and a batch of fixes.
If you're updating from 0.1.0, the built-in updater will fetch this for you.
## Install / update
- **Updating from 0.1.0:** the in-app updater will offer 0.1.1 on launch — one
click downloads, installs, and relaunches.
- **Fresh install:** download `Phokus_0.1.1_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 speed can use the
`Phokus_0.1.1_x64-cuda-setup.exe` variant instead (larger download; bundles the
CUDA runtime DLLs).
## Highlights
### Added
- **Custom multi-folder picker** — navigate and stage folders from multiple
locations and add them in one go, replacing the native OS dialog.
- **Themes** — Phokus, Subtle Light, and Conventional Dark chrome, with an
inline theme picker in first-run onboarding.
- **Timeline scrubber** — a year/month rail to jump anywhere in the library.
- **Folder reordering** in the sidebar (drag-and-drop or keyboard), persisted,
plus AZ / ZA / Custom sort for the Libraries list.
- **Video playback settings** — autoplay-in-lightbox and start-muted toggles.
- **Rebuild semantic index** maintenance action — fixes "dimension mismatch"
search errors after switching CLIP models.
- Locate and filter images with failed AI-tagging jobs.
### Changed
- Gallery grid and Duplicate Finder list are now row-virtualised — large
libraries and large result sets (5,000+ pairs) scroll without lag.
- Settings now open on General by default.
### Fixed
- **AVIF thumbnails** now generate correctly (routed through bundled FFmpeg);
previously-failed AVIF jobs are requeued on startup.
- Video embedding jobs no longer churn through failed states before their
thumbnail exists; previously-failed ones are requeued.
- Timeline scrolling is smooth on large libraries (per-row virtualisation);
background batches no longer re-sort the whole loaded set.
- Numerous Subtle Light theme readability/parity fixes.
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Checksums
```
SHA-256 (Phokus_0.1.1_x64-setup.exe) = 1c19cbeb77f38a44149380c42c76b633add65777d317e1f3ff7e45d96d12d287
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = a7337ef5ee0478a785b48acc8012e8fc5c957341161d6103409213ad78eb845f
```
+182
View File
@@ -0,0 +1,182 @@
# 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 --open
```
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` | First-run state with no folders or media |
| `/?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
```
## 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
```
+11 -4
View File
@@ -1,18 +1,23 @@
{
"name": "phokus",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"type": "module",
"scripts": {
"build:app": "tauri build",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"build:vite": "tsc && vite build",
"build:web": "cd website && tsc && vite build",
"changelog:add": "node tools/changelog-add.mjs",
"clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"dev:ui": "vite --mode ui --host 127.0.0.1 --port 1422 --strictPort --open",
"dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
"format:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview",
"tauri": "tauri"
},
@@ -32,9 +37,11 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2",
"@types/d3-force": "^3.0.10",
"@types/node": "^26.0.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+79
View File
@@ -0,0 +1,79 @@
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 : undefined,
/* 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://localhost:3000',
/* 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: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
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 your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
+457 -8
View File
@@ -48,15 +48,21 @@ importers:
specifier: ^5.0.12
version: 5.0.12(@types/react@19.2.14)(react@19.2.4)
devDependencies:
'@playwright/test':
specifier: ^1.61.1
version: 1.61.1
'@tailwindcss/vite':
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':
specifier: ^2
version: 2.10.1
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/node':
specifier: ^26.0.1
version: 26.0.1
'@types/react':
specifier: ^19.1.8
version: 19.2.14
@@ -65,7 +71,7 @@ importers:
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
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:
specifier: ^4.2.2
version: 4.2.2
@@ -74,7 +80,50 @@ importers:
version: 5.8.3
vite:
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)
website:
dependencies:
'@fontsource-variable/inter':
specifier: 5.2.8
version: 5.2.8
'@fontsource-variable/space-grotesk':
specifier: 5.2.10
version: 5.2.10
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.1.0
version: 19.2.4
react-dom:
specifier: ^19.1.0
version: 19.2.4(react@19.2.4)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
'@types/react':
specifier: ^19.1.8
version: 19.2.14
'@types/react-dom':
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
typescript:
specifier: ~5.8.3
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
vite-imagetools:
specifier: ^10.0.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:
@@ -161,6 +210,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -317,6 +369,165 @@ packages:
cpu: [x64]
os: [win32]
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/space-grotesk@5.2.10':
resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -333,9 +544,23 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
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':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm]
@@ -692,6 +917,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.0.1':
resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==}
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -770,6 +998,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -793,6 +1024,11 @@ packages:
react-dom:
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:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -805,6 +1041,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
imagetools-core@9.1.0:
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
engines: {node: '>=20.0.0'}
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -926,6 +1166,16 @@ packages:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
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:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
@@ -955,6 +1205,15 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -978,12 +1237,21 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
vite-imagetools@10.0.0:
resolution: {integrity: sha512-+83L32YPU/2BOHWhudO2+9T5HBvb3+0qHoUNN7fb0+XcAoXilx7aE25cDPWU5kBi5Yc750zYCvHxgfyR+tAuMA==}
engines: {node: '>=22.0.0'}
peerDependencies:
vite: '>=7.0.0'
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1159,6 +1427,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -1237,6 +1510,106 @@ snapshots:
'@esbuild/win32-x64@0.27.7':
optional: true
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/space-grotesk@5.2.10': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1256,8 +1629,20 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@playwright/test@1.61.1':
dependencies:
playwright: 1.61.1
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.4.0(rollup@4.60.1)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.4
optionalDependencies:
rollup: 4.60.1
'@rollup/rollup-android-arm-eabi@4.60.1':
optional: true
@@ -1394,12 +1779,12 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-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:
'@tailwindcss/node': 4.2.2
'@tailwindcss/oxide': 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)':
dependencies:
@@ -1507,6 +1892,10 @@ snapshots:
'@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)':
dependencies:
'@types/react': 19.2.14
@@ -1515,7 +1904,7 @@ snapshots:
dependencies:
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:
'@babel/core': 7.29.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
@@ -1523,7 +1912,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
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:
- supports-color
@@ -1599,6 +1988,8 @@ snapshots:
escalade@3.2.0: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1612,6 +2003,9 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
fsevents@2.3.2:
optional: true
fsevents@2.3.3:
optional: true
@@ -1619,6 +2013,8 @@ snapshots:
graceful-fs@4.2.11: {}
imagetools-core@9.1.0: {}
jiti@2.6.1: {}
js-tokens@4.0.0: {}
@@ -1700,6 +2096,14 @@ snapshots:
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:
dependencies:
nanoid: 3.3.11
@@ -1750,6 +2154,39 @@ snapshots:
semver@6.3.1: {}
semver@7.8.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
source-map-js@1.2.1: {}
tailwindcss@4.2.2: {}
@@ -1765,13 +2202,24 @@ snapshots:
typescript@5.8.3: {}
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
escalade: 3.2.0
picocolors: 1.1.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:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1)
imagetools-core: 9.1.0
sharp: 0.34.5
vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies:
- rollup
vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -1780,6 +2228,7 @@ snapshots:
rollup: 4.60.1
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 26.0.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.32.0
+4
View File
@@ -1,2 +1,6 @@
packages:
- website
allowBuilds:
esbuild: true
sharp: true
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

+43
View File
@@ -0,0 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256" role="img" aria-label="Phokus">
<title>Phokus</title>
<!--
Phokus app mark — a camera iris.
The hexagon in the middle is the lens OPENING; each blade edge sweeps from a
corner of the opening out to the rim, all leaning the same way (the pinwheel).
Tuning knobs (edit and re-open in any browser/Figma):
* opening roundness -> the "52" radius in the opening <path> arcs.
smaller (toward 30) = rounder/softer hole; larger (toward 90) = flatter, sharper.
* blade curve -> the "110" radius in each blade <path>.
smaller = more pronounced curve; larger = straighter blade.
* blade sweep amount -> the +40deg baked into the blade endpoint (70.71,-84.27).
* curve DIRECTION -> the final flag in each "A r r 0 0 X" command (0<->1 flips the bow).
* weight -> stroke-width on the <g>.
* color -> stroke on the <g>; swap "#e9e9ec" for currentColor to inherit.
A brand-accent focal dot is provided at the bottom (commented out).
-->
<rect x="8" y="8" width="240" height="240" rx="52" fill="#0e0f14"/>
<g transform="translate(128 128)" fill="none" stroke="#e9e9ec" stroke-width="9"
stroke-linecap="round" stroke-linejoin="round">
<!-- outer rim -->
<circle r="110"/>
<!-- lens opening: soft, arc-rounded hexagon -->
<path d="M0,-46 A52,52 0 0 0 39.84,-23 A52,52 0 0 0 39.84,23 A52,52 0 0 0 0,46 A52,52 0 0 0 -39.84,23 A52,52 0 0 0 -39.84,-23 A52,52 0 0 0 0,-46 Z"/>
<!-- blades: one curved edge, swept six times -->
<g transform="rotate(0)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(60)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(120)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(180)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(240)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
<g transform="rotate(300)"><path d="M0,-46 A110,110 0 0 1 70.71,-84.27"/></g>
</g>
<!-- optional brand focal point -->
<!-- <circle cx="128" cy="128" r="9" fill="#e6a23c"/> -->
</svg>

After

Width:  |  Height:  |  Size: 2.2 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]]
name = "phokus"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"candle-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "phokus"
version = "0.1.0"
version = "0.1.1"
description = "Local-first desktop media library"
authors = ["JezzWTF"]
license = "MIT"
+45
View File
@@ -0,0 +1,45 @@
/// 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");
}
}
}
+4 -14
View File
@@ -78,9 +78,10 @@ static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionAcceleration {
#[default]
Auto,
Cpu,
Directml,
@@ -96,17 +97,12 @@ impl CaptionAcceleration {
}
}
impl Default for CaptionAcceleration {
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CaptionDetail {
Short,
Detailed,
#[default]
Paragraph,
}
@@ -136,12 +132,6 @@ impl CaptionDetail {
}
}
impl Default for CaptionDetail {
fn default() -> Self {
Self::Paragraph
}
}
#[derive(Serialize)]
pub struct CaptionModelStatus {
pub model_id: &'static str,
+91
View File
@@ -0,0 +1,91 @@
//! Dominant-color palette extraction for color search.
//!
//! Colors are sampled from the already-generated thumbnail (small, fast) rather
//! than the full image. We coarse-quantize pixels into an RGB histogram, then
//! return the most populated bins as representative colors with their weight
//! (fraction of sampled pixels). Search then filters images whose palette has a
//! color within a distance threshold of the query color.
use image::RgbImage;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct PaletteColor {
pub r: u8,
pub g: u8,
pub b: u8,
/// Fraction of sampled pixels (0.01.0) that fell in this color's bin.
pub weight: f32,
}
/// Bits kept per channel when binning. 4 bits → 16 levels/channel → 4096 bins:
/// coarse enough to group near-identical shades, fine enough to separate hues.
const QUANT_BITS: u32 = 4;
/// Cap on sampled pixels so very large frames stay cheap; thumbnails are tiny so
/// this rarely bites, but the backfill may read arbitrary thumbnail sizes.
const MAX_SAMPLES: usize = 50_000;
/// Extract up to `k` dominant colors from an RGB image, most-common first.
pub fn extract_palette(img: &RgbImage, k: usize) -> Vec<PaletteColor> {
let pixels = img.as_raw();
let pixel_count = pixels.len() / 3;
if pixel_count == 0 {
return Vec::new();
}
let step = (pixel_count / MAX_SAMPLES).max(1);
let shift = 8 - QUANT_BITS;
// bin key → (sum_r, sum_g, sum_b, count); summing lets us return the bin's
// average color rather than the quantized corner.
let mut bins: HashMap<u16, (u64, u64, u64, u64)> = HashMap::new();
let mut total: u64 = 0;
for pixel in pixels.chunks_exact(3).step_by(step) {
let (r, g, b) = (pixel[0], pixel[1], pixel[2]);
let key = (((r as u16) >> shift) << (QUANT_BITS * 2))
| (((g as u16) >> shift) << QUANT_BITS)
| ((b as u16) >> shift);
let entry = bins.entry(key).or_insert((0, 0, 0, 0));
entry.0 += r as u64;
entry.1 += g as u64;
entry.2 += b as u64;
entry.3 += 1;
total += 1;
}
if total == 0 {
return Vec::new();
}
let mut entries: Vec<(u64, u64, u64, u64)> = bins.into_values().collect();
entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.3));
entries
.into_iter()
.take(k)
.map(|(sum_r, sum_g, sum_b, count)| PaletteColor {
r: (sum_r / count) as u8,
g: (sum_g / count) as u8,
b: (sum_b / count) as u8,
weight: count as f32 / total as f32,
})
.collect()
}
/// Decode a thumbnail file and extract its palette. Used by the backfill pass.
pub fn extract_palette_from_file(thumbnail_path: &Path, k: usize) -> Option<Vec<PaletteColor>> {
let img = image::ImageReader::open(thumbnail_path)
.ok()?
.decode()
.ok()?;
Some(extract_palette(&img.into_rgb8(), k))
}
/// Number of palette colors stored per image.
pub const PALETTE_SIZE: usize = 5;
/// Max squared RGB distance for a palette color to count as matching a query
/// color (~70 units in RGB space). Tunable feel/precision of color search.
pub const MATCH_DISTANCE_SQ: i64 = 4900;
/// Minimum weight (fraction of pixels) a palette color must have to match, so
/// trivial specks of a color don't trigger a match.
pub const MATCH_MIN_WEIGHT: f64 = 0.05;
+1095 -75
View File
File diff suppressed because it is too large Load Diff
+821 -28
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -220,19 +220,19 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
/// Returns the path that should be fed to the CLIP image embedder for a given media file.
///
/// For videos the thumbnail image is used (because CLIP only understands still images).
/// If a video has no thumbnail yet, an error is returned — the caller should mark the
/// embedding job as failed rather than trying to decode the raw video file.
/// For videos and AVIFs the thumbnail image is used because CLIP preprocessing
/// only uses decoders from the `image` crate, while AVIF is decoded through
/// FFmpeg into a JPEG thumbnail.
pub fn embedding_source_path(
path: &str,
thumbnail_path: Option<&str>,
media_kind: &str,
) -> Result<PathBuf> {
if media_kind == "video" {
if media_kind == "video" || is_avif_path(path) {
match thumbnail_path {
Some(thumb) => Ok(PathBuf::from(thumb)),
None => Err(anyhow::anyhow!(
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated",
"No thumbnail available yet for '{}' — embedding deferred until thumbnail is generated",
std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy())
@@ -243,3 +243,10 @@ pub fn embedding_source_path(
Ok(PathBuf::from(path))
}
}
fn is_avif_path(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
+12 -4
View File
@@ -92,6 +92,7 @@ pub fn find_similar_image_matches(
conn: &Connection,
image_id: i64,
folder_id: Option<i64>,
album_id: Option<i64>,
threshold: f32,
offset: usize,
limit: usize,
@@ -103,10 +104,17 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()),
};
// Fetch folder image IDs *before* acquiring the read lock so we don't hold
// Build the allowed-id set *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock.
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id {
// concurrent ensure_index call waiting for a write lock. Album scope takes
// precedence over folder scope; both reuse the HNSW filtered search.
let allowed_image_ids: Option<Vec<i64>> = if let Some(album_id) = album_id {
let mut stmt = conn.prepare("SELECT image_id FROM album_images WHERE album_id = ?1")?;
let ids = stmt
.query_map([album_id], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(ids)
} else if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.map(|(id, _)| id)
@@ -122,7 +130,7 @@ pub fn find_similar_image_matches(
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
let neighbours: Vec<Neighbour> = if let Some(image_ids) = allowed_image_ids {
let mut allowed_ids = image_ids
.into_iter()
.filter_map(|allowed_image_id| {
+283 -29
View File
@@ -3,22 +3,22 @@ use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, Inde
use crate::embedder::{embedding_source_path, ClipImageEmbedder};
use crate::media::{probe_video_metadata, MediaTools};
use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile};
use crate::tagger::{self, WdTagger};
use crate::tagger::{self, Tagger};
use crate::thumbnail;
use crate::vector;
use anyhow::Result;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use rayon::prelude::*;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use walkdir::WalkDir;
const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", "heic", "heif",
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif",
];
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
@@ -41,6 +41,14 @@ struct PausedWorkerFolders {
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)]
pub struct FolderWorkerPausedState {
pub thumbnail: bool,
@@ -50,6 +58,41 @@ pub struct FolderWorkerPausedState {
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) {
if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS
.get_or_init(|| Mutex::new(PausedWorkerFolders::default()))
@@ -190,6 +233,13 @@ pub struct MediaUpdateBatch {
pub images: Vec<ImageRecord>,
}
#[derive(Clone, Serialize)]
pub struct ColorBackfillProgress {
pub processed: i64,
pub total: i64,
pub done: bool,
}
#[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent {
pub progress: Vec<FolderJobProgress>,
@@ -323,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) {
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.");
loop {
// If the acceleration setting changed, drop the cached session so
@@ -347,7 +397,51 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
});
}
/// True when `path` lives inside Phokus's own app-data directory (thumbnail
/// cache, database, downloaded models). If a user indexes an ancestor of this
/// directory — e.g. their whole `Users` folder — the app would otherwise index
/// its own thumbnails, generate thumbnails of those thumbnails, and loop
/// forever. The whole subtree is pruned from folder scans and ignored by the
/// watcher to break that cycle.
///
/// Comparison is component-aware (so a sibling like `…/phokus-backup` never
/// matches) and case-insensitive on Windows — the indexed root may report a
/// different casing than `app_data_dir` (e.g. `c:\users\…` vs `C:\Users\…`),
/// and a case-sensitive prefix check there would silently let the loop back in.
fn is_within_app_data(path: &Path, app_data_dir: Option<&Path>) -> bool {
let Some(dir) = app_data_dir else {
return false;
};
let mut base = dir.components();
let mut target = path.components();
loop {
let Some(base_component) = base.next() else {
// Consumed every component of the app-data dir while still matching →
// `path` is the app-data dir itself or a descendant.
return true;
};
let Some(target_component) = target.next() else {
// `path` is shorter than the app-data dir, so it cannot be inside it.
return false;
};
let matches = if cfg!(windows) {
base_component
.as_os_str()
.eq_ignore_ascii_case(target_component.as_os_str())
} else {
base_component == target_component
};
if !matches {
return false;
}
}
}
fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
// Resolve our own app-data directory so the walk can skip it (see
// `is_within_app_data`). Resolution failure is non-fatal — we just lose the
// guard, which only matters when indexing an ancestor of the app data dir.
let app_data_dir = app.path().app_data_dir().ok();
let existing_entries = {
let conn = pool.get()?;
db::get_folder_media_index(&conn, folder_id)?
@@ -364,6 +458,9 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
.follow_links(true)
.into_iter()
// Prune our own app-data subtree before descending into it, so we never
// scan (and re-thumbnail) the thumbnail cache.
.filter_entry(|entry| !is_within_app_data(entry.path(), app_data_dir.as_deref()))
.filter_map(|entry| match entry {
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
Some(e.path().to_path_buf())
@@ -550,6 +647,9 @@ fn build_record(
let filename = path.file_name()?.to_string_lossy().to_string();
let metadata = std::fs::metadata(path).ok()?;
let file_size = metadata.len() as i64;
if file_size == 0 {
return None;
}
let modified_at = metadata.modified().ok().map(|time| {
let date_time: chrono::DateTime<chrono::Utc> = time.into();
date_time.to_rfc3339()
@@ -660,10 +760,13 @@ fn process_thumbnail_batch(
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image");
let (avif_jobs, raster_jobs): (Vec<_>, Vec<_>) = image_jobs
.into_iter()
.partition(|job| is_avif_path(Path::new(&job.path)));
// Images: parallel decode, committed as one batch.
if !image_jobs.is_empty() {
let results = image_jobs
if !raster_jobs.is_empty() {
let results = raster_jobs
.par_iter()
.map(|job| {
(
@@ -675,6 +778,15 @@ fn process_thumbnail_batch(
persist_thumbnail_results(app, pool, results)?;
}
// AVIF: FFmpeg-backed decode, like videos. Keep this off the shared rayon
// pool because each subprocess blocks its worker thread.
for job in &avif_jobs {
let result =
thumbnail::generate_avif_thumbnail(media_tools, Path::new(&job.path), cache_dir)
.map(Some);
persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
}
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its
// thread, and a video-heavy batch on the shared pool would starve image
// decoding across all workers. Committed per item so progress keeps
@@ -722,6 +834,13 @@ fn persist_thumbnail_results(
width,
height,
)?);
// Store the dominant-color palette sampled during resizing.
if let Some(thumb) = &generated {
if !thumb.palette.is_empty() {
db::replace_image_colors(&tx, image_id, &thumb.palette)?;
}
}
}
tx.commit()?;
@@ -746,6 +865,82 @@ fn persist_thumbnail_results(
Ok(())
}
fn is_avif_path(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
/// One-shot background pass that samples a color palette from the (already
/// generated) thumbnails of images indexed before color search existed. New
/// images get their palette during thumbnail generation, so this only fills the
/// historical gap. Emits `color-backfill-progress` so the UI can show a count.
pub fn start_color_backfill(app: AppHandle, pool: DbPool) {
std::thread::spawn(move || {
let total = match pool.get() {
Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0),
Err(_) => return,
};
if total == 0 {
return;
}
log::info!("Color backfill: sampling palettes for {total} images.");
let mut processed: i64 = 0;
loop {
let batch = match pool.get() {
Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(),
Err(_) => break,
};
if batch.is_empty() {
break;
}
for (image_id, thumbnail_path) in batch {
let palette = crate::color::extract_palette_from_file(
Path::new(&thumbnail_path),
crate::color::PALETTE_SIZE,
);
let colors: Vec<(u8, u8, u8, f32)> = match palette {
Some(colors) if !colors.is_empty() => colors
.into_iter()
.map(|c| (c.r, c.g, c.b, c.weight))
.collect(),
// Unreadable thumbnail: store a zero-weight sentinel so the
// image isn't reprocessed forever (it just won't match).
_ => vec![(0, 0, 0, 0.0)],
};
let _ = with_db_write_lock(|| {
let conn = pool.get()?;
db::replace_image_colors(&conn, image_id, &colors)
});
processed += 1;
}
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: false,
},
);
// Yield so the backfill stays in the background under active use.
std::thread::sleep(Duration::from_millis(15));
}
log::info!("Color backfill complete: {processed} images sampled.");
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: true,
},
);
});
}
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty.
fn process_metadata_batch(
@@ -869,20 +1064,20 @@ fn process_embedding_batch(
let embedder = embedder.as_ref().expect("embedder should be initialized");
let infer_started_at = Instant::now();
// Resolve the source path for each job. Videos without a thumbnail produce an Err
// here — those jobs are marked failed immediately without going to the embedder.
// Resolve each source path. Video jobs without thumbnails are not claimable, so an
// error here represents a real race or missing thumbnail rather than normal deferral.
let source_results: Vec<Result<PathBuf>> = jobs
.iter()
.map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
.collect();
// Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail).
// Separate jobs with a valid source from genuine early failures.
let mut embeddable_indices: Vec<usize> = Vec::new();
let mut embeddable_paths: Vec<PathBuf> = Vec::new();
// image_id -> early error message for jobs that cannot be embedded yet
let mut pre_failed: HashMap<i64, String> = HashMap::new();
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() {
for (i, (job, result)) in jobs.iter().zip(source_results).enumerate() {
match result {
Ok(path) => {
embeddable_indices.push(i);
@@ -904,16 +1099,13 @@ fn process_embedding_batch(
match embedder.embed_images(&embeddable_paths) {
Ok(embeddings) => {
for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) {
for (job, embedding) in embeddable_jobs.iter().zip(embeddings) {
embed_results.insert(job.image_id, Ok(embedding));
}
}
Err(batch_error) => {
log::error!("Embedding batch fallback to per-image mode: {batch_error}");
for (job, source_path) in embeddable_jobs
.into_iter()
.zip(embeddable_paths.into_iter())
{
for (job, source_path) in embeddable_jobs.into_iter().zip(embeddable_paths) {
embed_results.insert(job.image_id, embedder.embed_image(&source_path));
}
}
@@ -1087,7 +1279,7 @@ fn process_tagging_batch(
app: &AppHandle,
pool: &DbPool,
app_data_dir: &Path,
tagger_instance: &mut Option<WdTagger>,
tagger_instance: &mut Option<Box<dyn Tagger>>,
) -> Result<bool> {
if !tagger::tagger_model_status(app_data_dir).ready {
return Ok(false);
@@ -1099,20 +1291,23 @@ fn process_tagging_batch(
// Exclude actively-indexing folders for the same reason as the other
// workers: don't compete with a running scan.
let batch_started_at = Instant::now();
let mut excluded_folders = paused_folder_ids("tagging");
excluded_folders.extend(active_indexing_folders());
let batch_size = crate::tagger::tagger_batch_size(app_data_dir);
let claim_started_at = Instant::now();
let jobs = with_db_write_lock(|| {
let mut conn = pool.get()?;
db::claim_tagging_jobs(&mut conn, &excluded_folders, batch_size)
})?;
let claim_elapsed = claim_started_at.elapsed();
if jobs.is_empty() {
return Ok(false);
}
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),
Err(error) => {
with_db_write_lock(|| {
@@ -1139,16 +1334,45 @@ fn process_tagging_batch(
.as_mut()
.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()
.map(|job| {
(
job.clone(),
tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS),
)
if is_avif_path(Path::new(&job.path)) {
PathBuf::from(job.thumbnail_path.as_deref().unwrap_or(&job.path))
} else {
PathBuf::from(&job.path)
}
})
.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 mut conn = pool.get()?;
let tx = conn.transaction()?;
@@ -1178,7 +1402,7 @@ fn process_tagging_batch(
job.image_id,
&tag_pairs,
&output.rating,
tagger::WD_TAGGER_MODEL_NAME,
tagger_model_name,
)?;
}
Err(error) => {
@@ -1200,6 +1424,7 @@ fn process_tagging_batch(
db::requeue_tagging_jobs(&conn, &image_ids)
});
})?;
let write_elapsed = write_started_at.elapsed();
if !updated_images.is_empty() {
let folder_ids = updated_images
@@ -1215,6 +1440,15 @@ fn process_tagging_batch(
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)
}
@@ -1285,7 +1519,7 @@ fn max_worker_fetch_size(active_folders: &HashSet<i64>) -> usize {
.unwrap_or(StorageProfile::Balanced.worker_fetch_size())
}
fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
pub fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock.lock().unwrap();
operation()
@@ -1372,7 +1606,6 @@ fn mime_for_ext(ext: &str) -> &'static str {
"webp" => "image/webp",
"tiff" | "tif" => "image/tiff",
"avif" => "image/avif",
"heic" | "heif" => "image/heif",
"mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime",
"webm" => "video/webm",
@@ -1491,6 +1724,10 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
// Spawn the debounce loop on its own thread.
let folder_map_thread = Arc::clone(&folder_map);
// `thumb_dir` is `<app data>/thumbnails`; its parent is the app-data root.
// Watched roots can be ancestors of it (e.g. a whole user profile), so we
// drop any event inside that subtree to avoid re-indexing our own cache.
let app_data_dir = thumb_dir.parent().map(Path::to_path_buf);
std::thread::spawn(move || {
// path → deadline: the earliest instant at which this path should be processed.
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
@@ -1533,7 +1770,22 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
{
let old = event.paths[0].clone();
let new = event.paths[1].clone();
if is_supported_media(&old) || is_supported_media(&new) {
let old_in_app = is_within_app_data(&old, app_data_dir.as_deref());
let new_in_app = is_within_app_data(&new, app_data_dir.as_deref());
if old_in_app && new_in_app {
// Internal app-data churn (e.g. thumbnail cache) — ignore.
} else if old_in_app || new_in_app {
// Only one side is app-data, so the rename pairing is
// meaningless (we don't track app-data files). Handle the
// legitimate side as an independent create/delete via the
// normal debounce queue: a file moved out of app-data is
// indexed as a create; one moved in is processed as a
// delete (process_watcher_path sees it no longer exists).
let legit = if old_in_app { new } else { old };
if is_supported_media(&legit) {
pending.insert(legit, now + WATCHER_DEBOUNCE);
}
} else if is_supported_media(&old) || is_supported_media(&new) {
// Remove either side from regular pending so it isn't
// processed as an independent delete/create.
pending.remove(&old);
@@ -1542,7 +1794,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
}
} else {
for path in event.paths {
if is_supported_media(&path) {
if is_supported_media(&path)
&& !is_within_app_data(&path, app_data_dir.as_deref())
{
pending.insert(path, now + WATCHER_DEBOUNCE);
}
}
+74 -2
View File
@@ -1,4 +1,6 @@
mod ai_tag_filter;
mod captioner;
mod color;
mod commands;
mod db;
mod embedder;
@@ -45,6 +47,32 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| {
// Fresh installs open at the fixed config size (1280×800) because the
// window-state plugin has nothing saved yet — too tall for laptops
// like 1366×768. Clamp the window to the monitor's work area (which
// already excludes the taskbar) and re-center it there, but only when
// it actually overflows, so a restored size/position on a roomier
// display is left untouched. Sizes are physical pixels on both sides,
// so this stays correct across display scaling.
if let Some(window) = app.get_webview_window("main") {
if let Ok(Some(monitor)) = window.current_monitor() {
let area = monitor.work_area();
let max_w = area.size.width.saturating_sub(32);
let max_h = area.size.height.saturating_sub(32);
if let Ok(size) = window.outer_size() {
if size.width > max_w || size.height > max_h {
let new_w = size.width.min(max_w);
let new_h = size.height.min(max_h);
let _ = window.set_size(tauri::PhysicalSize::new(new_w, new_h));
let _ = window.set_position(tauri::PhysicalPosition::new(
area.position.x + (area.size.width as i32 - new_w as i32) / 2,
area.position.y + (area.size.height as i32 - new_h as i32) / 2,
));
}
}
}
}
let app_dir = app
.path()
.app_data_dir()
@@ -53,7 +81,7 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
// FFmpeg provisioning happens in the background so the window
// appears immediately; workers gate video jobs on readiness and
// appears immediately; workers gate video/AVIF jobs on readiness and
// the onboarding/Settings UI shows progress and retry.
media::spawn_ffmpeg_provision(app.handle().clone());
@@ -65,6 +93,16 @@ pub fn run() {
let conn = pool.get().expect("Failed to get connection for migration");
db::migrate(&conn).expect("Failed to run migrations");
db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs");
let repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
.expect("Failed to repair deferred embedding jobs");
if repaired_deferred > 0 {
log::info!("Requeued {repaired_deferred} deferred embedding jobs.");
}
let repaired_avif =
db::repair_avif_jobs(&conn).expect("Failed to repair AVIF jobs");
if repaired_avif > 0 {
log::info!("Requeued {repaired_avif} AVIF jobs.");
}
let backfilled =
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 {
@@ -81,6 +119,7 @@ pub fn run() {
let thumb_dir = app_dir.join("thumbnails");
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
// are allowed statically in tauri.conf.json, and each indexed
@@ -112,6 +151,8 @@ pub fn run() {
// Caption worker disabled — UI removed; keeping backend code intact for future use.
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
// Backfill color palettes for images indexed before color search existed.
indexer::start_color_backfill(app.handle().clone(), pool.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
@@ -123,7 +164,10 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::add_folder,
commands::add_folders,
commands::list_directories,
commands::get_folders,
commands::reorder_folders,
commands::get_background_job_progress,
commands::remove_folder,
commands::get_images,
@@ -152,13 +196,19 @@ pub fn run() {
commands::suggest_image_tags,
commands::set_worker_paused,
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_related_tags,
commands::get_images_by_ids,
commands::get_failed_embedding_images,
commands::get_failed_tagging_images,
commands::get_tagger_model_status,
commands::get_tagger_acceleration,
commands::set_tagger_acceleration,
commands::get_tagger_model,
commands::set_tagger_model,
commands::probe_tagger_runtime,
commands::get_tagger_threshold,
commands::set_tagger_threshold,
@@ -168,9 +218,26 @@ pub fn run() {
commands::delete_tagger_model,
commands::queue_tagging_jobs,
commands::clear_tagging_jobs,
commands::reset_ai_tags,
commands::get_image_tags,
commands::add_user_tag,
commands::remove_tag,
commands::rename_tag,
commands::delete_tag,
commands::get_image_exif,
commands::list_albums,
commands::create_album,
commands::rename_album,
commands::delete_album,
commands::delete_albums,
commands::reorder_albums,
commands::add_images_to_album,
commands::remove_images_from_album,
commands::get_album_images,
commands::bulk_update_details,
commands::bulk_add_tags,
commands::bulk_remove_tag,
commands::get_build_variant,
commands::search_tags_autocomplete,
commands::find_duplicates,
commands::load_duplicate_scan_cache,
@@ -183,8 +250,11 @@ pub fn run() {
commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder,
commands::open_map_location,
commands::open_changelog_url,
commands::get_database_info,
commands::vacuum_database,
commands::rebuild_semantic_index,
commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids,
@@ -193,6 +263,8 @@ pub fn run() {
commands::retry_ffmpeg_download,
commands::get_onboarding_completed,
commands::set_onboarding_completed,
commands::get_last_seen_version,
commands::set_last_seen_version,
commands::get_notifications_paused,
commands::set_notifications_paused,
])
+608 -77
View File
@@ -1,9 +1,11 @@
use crate::ai_tag_filter;
use anyhow::Result;
use hf_hub::{api::sync::Api, Repo, RepoType};
use image::{imageops::FilterType, DynamicImage, ImageReader};
use ort::ep;
use ort::session::{builder::GraphOptimizationLevel, Session};
use ort::value::Tensor;
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -15,15 +17,27 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt";
// Files required on disk before the tagger can run. The ONNX runtime DLLs
// are shared with the captioner and live in the same `onnxruntime/` directory.
const TAGGER_REQUIRED_FILES: &[&str] = &[
pub const JOYTAG_MODEL_ID: &str = "fancyfeast/joytag";
pub const JOYTAG_MODEL_NAME: &str = "joytag";
// JoyTag preprocessing differs from the WD tagger: it expects RGB (not BGR),
// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants.
const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73];
const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
// JoyTag's recommended detection threshold (used as the default; the user's
// tagger_threshold setting still overrides it).
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and
// are reused by the tagger). The per-model weight + label files are listed by
// `TaggerModel::download_files`.
const TAGGER_RUNTIME_DLLS: &[&str] = &[
"onnxruntime/onnxruntime.dll",
"onnxruntime/onnxruntime_providers_shared.dll",
"onnxruntime/DirectML.dll",
"model.onnx",
"selected_tags.csv",
];
// Tags in these Danbooru categories are kept in the output.
@@ -37,6 +51,19 @@ const RATING_CATEGORY: u8 = 9;
pub const DEFAULT_THRESHOLD: f32 = 0.35;
pub const DEFAULT_MAX_TAGS: usize = 30;
/// How many images are fed to the GPU in a single forward pass, regardless of
/// how many the worker claims from the DB at once. The WD model is compute-
/// bound on a shared GPU, so a wide batch buys little throughput but holds the
/// GPU (and therefore the WebView2 compositor) hostage for its whole duration,
/// freezing the UI. Small chunks keep each DirectML dispatch short — lower this
/// for a smoother UI under heavy tagging, raise it for marginally higher
/// throughput on a dedicated GPU.
pub const TAGGER_INFER_CHUNK: usize = 4;
/// Brief pause between inference chunks so the compositor and other workers can
/// claim the GPU/CPU between dispatches. Negligible against per-chunk inference.
pub const TAGGER_INFER_YIELD_MS: u64 = 40;
/// Set to `true` by `set_tagger_acceleration` so the tagging worker loop
/// knows to drop its cached `WdTagger` and reload with the new EP.
pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
@@ -45,9 +72,10 @@ pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
// Settings types
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaggerAcceleration {
#[default]
Auto,
Cpu,
Directml,
@@ -63,9 +91,58 @@ impl TaggerAcceleration {
}
}
impl Default for TaggerAcceleration {
fn default() -> Self {
Self::Auto
/// Which tagging model is active. Both produce a `TaggerOutput` (tags + an
/// explicitness rating); they differ in vocabulary, preprocessing, and how the
/// rating is derived. WD is Danbooru-trained (anime-leaning); JoyTag uses the
/// Danbooru schema but generalizes to photographic content and is stronger on
/// NSFW concepts.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaggerModel {
#[default]
Wd,
JoyTag,
}
impl TaggerModel {
fn as_str(self) -> &'static str {
match self {
Self::Wd => "wd",
Self::JoyTag => "joytag",
}
}
/// Hugging Face repo the model files are fetched from.
fn repo_id(self) -> &'static str {
match self {
Self::Wd => WD_TAGGER_MODEL_ID,
Self::JoyTag => JOYTAG_MODEL_ID,
}
}
/// Stable display/identifier name.
fn model_name(self) -> &'static str {
match self {
Self::Wd => WD_TAGGER_MODEL_NAME,
Self::JoyTag => JOYTAG_MODEL_NAME,
}
}
/// Subdirectory under `models/` where this model's files live.
fn dir_name(self) -> &'static str {
match self {
Self::Wd => "wd-swinv2-tagger-v3",
Self::JoyTag => "joytag",
}
}
/// Files fetched from `repo_id` (the shared ONNX Runtime DLLs are handled
/// separately). `model.onnx` is the weights; the second is the label list.
fn download_files(self) -> &'static [&'static str] {
match self {
Self::Wd => &["model.onnx", "selected_tags.csv"],
Self::JoyTag => &["model.onnx", "top_tags.txt"],
}
}
}
@@ -143,14 +220,41 @@ struct TagEntry {
// Path helpers
// ---------------------------------------------------------------------------
/// Directory of the *active* tagger model's files (driven by the
/// `tagger_model` setting), e.g. `…/models/wd-swinv2-tagger-v3`.
pub fn model_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("models").join("wd-swinv2-tagger-v3")
app_data_dir
.join("models")
.join(tagger_model(app_data_dir).dir_name())
}
// ---------------------------------------------------------------------------
// Settings persistence
// ---------------------------------------------------------------------------
pub fn tagger_model(app_data_dir: &Path) -> TaggerModel {
let path = app_data_dir.join(TAGGER_MODEL_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
return TaggerModel::default();
};
match value.trim().to_ascii_lowercase().as_str() {
"joytag" => TaggerModel::JoyTag,
_ => TaggerModel::Wd,
}
}
pub fn set_tagger_model(app_data_dir: &Path, model: TaggerModel) -> Result<TaggerModel> {
let path = app_data_dir.join(TAGGER_MODEL_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, model.as_str())?;
// Switching models means the cached session is for the wrong model; the
// worker drops and rebuilds it on the next batch (same flag as an EP change).
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(model)
}
pub fn tagger_acceleration(app_data_dir: &Path) -> TaggerAcceleration {
let path = app_data_dir.join(TAGGER_ACCELERATION_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
@@ -222,25 +326,27 @@ pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<u
// ---------------------------------------------------------------------------
pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus {
let model = tagger_model(app_data_dir);
let local_dir = model_dir(app_data_dir);
// The ONNX runtime DLLs live in the caption model dir; reuse them.
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
let missing_files = TAGGER_REQUIRED_FILES
let mut missing_files: Vec<String> = TAGGER_RUNTIME_DLLS
.iter()
.filter(|file| {
let path = if file.starts_with("onnxruntime/") {
caption_model_dir.join(file)
} else {
local_dir.join(file)
};
!path.exists()
})
.filter(|file| !caption_model_dir.join(file).exists())
.map(|file| (*file).to_string())
.collect::<Vec<_>>();
.collect();
missing_files.extend(
model
.download_files()
.iter()
.filter(|file| !local_dir.join(file).exists())
.map(|file| (*file).to_string()),
);
TaggerModelStatus {
model_id: WD_TAGGER_MODEL_ID,
model_name: WD_TAGGER_MODEL_NAME,
model_id: model.repo_id(),
model_name: model.model_name(),
local_dir: local_dir.to_string_lossy().to_string(),
ready: missing_files.is_empty(),
missing_files,
@@ -251,19 +357,20 @@ pub fn prepare_tagger_model_with_progress(
app_data_dir: &Path,
emit_progress: impl Fn(TaggerModelProgress),
) -> Result<TaggerModelStatus> {
let model = tagger_model(app_data_dir);
let local_dir = model_dir(app_data_dir);
std::fs::create_dir_all(&local_dir)?;
// The tagger shares the ONNX runtime DLLs with the captioner; download
// them here so the tagger works even on a clean install where the caption
// model has never been fetched (ensure_onnx_runtime only initializes).
const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"];
let download_files = model.download_files();
let caption_model_dir = crate::captioner::model_dir(app_data_dir);
std::fs::create_dir_all(&caption_model_dir)?;
// Unified step count across DLLs and tagger files so the bar is coherent.
let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir);
let model_pending = DOWNLOAD_FILES
let model_pending = download_files
.iter()
.filter(|file| !local_dir.join(file).exists())
.count();
@@ -309,9 +416,9 @@ pub fn prepare_tagger_model_with_progress(
// (timeout + resume), rather than hf-hub's download_with_progress, whose
// agent has no read timeout and would hang on a stalled connection.
let api = Api::new()?;
let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model));
let repo = api.repo(Repo::new(model.repo_id().to_string(), RepoType::Model));
for file in DOWNLOAD_FILES {
for file in download_files {
let destination = local_dir.join(file);
if destination.exists() {
continue;
@@ -415,11 +522,96 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
}
// ---------------------------------------------------------------------------
// Top-level inference entry point
// Tagger trait + shared batch skeleton
// ---------------------------------------------------------------------------
/// A loaded tagging model. Implementations differ in vocabulary, preprocessing,
/// and how the explicitness rating is derived, but all turn a batch of image
/// paths into one `TaggerOutput` per path (in order), with per-image failures
/// reflected in the individual `Result`s. Built for the active model by
/// [`create_active_tagger`].
pub trait Tagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>>;
/// Stable name of this model, written to the DB as `ai_tagger_model` so
/// tags can be attributed to (and re-tagged across) models.
fn model_name(&self) -> &'static str;
}
/// Build the tagger for the currently-selected model.
pub fn create_active_tagger(app_data_dir: &Path) -> Result<Box<dyn Tagger>> {
match tagger_model(app_data_dir) {
TaggerModel::Wd => Ok(Box::new(WdTagger::new(app_data_dir)?)),
TaggerModel::JoyTag => Ok(Box::new(JoyTagger::new(app_data_dir)?)),
}
}
/// Shared batch skeleton: pack the successfully-preprocessed images into one
/// contiguous buffer, run a single batched forward pass via `infer`, and fall
/// back to per-image inference if the batch fails (e.g. a model pinned to
/// batch=1). Returns one result per input slot, in order — a decode failure
/// stays attached to its own slot. `infer(pixels, count)` runs `count`
/// contiguous images (`count * stride` floats) and returns `count` outputs.
fn assemble_batch(
preprocessed: Vec<Result<Vec<f32>>>,
stride: usize,
model_label: &str,
mut infer: impl FnMut(&[f32], usize) -> Result<Vec<TaggerOutput>>,
) -> Vec<Result<TaggerOutput>> {
let mut batch_slots: Vec<usize> = Vec::new();
let mut batch_pixels: Vec<f32> = Vec::with_capacity(preprocessed.len() * stride);
for (i, result) in preprocessed.iter().enumerate() {
if let Ok(pixels) = result {
batch_slots.push(i);
batch_pixels.extend_from_slice(pixels);
}
}
// Seed each slot with its decode error; inference overwrites decoded slots.
let mut results: Vec<Result<TaggerOutput>> = preprocessed
.into_iter()
.map(|r| match r {
Ok(_) => Err(anyhow::anyhow!(
"tagging inference did not run for this image"
)),
Err(error) => Err(error),
})
.collect();
if batch_slots.is_empty() {
return results; // nothing decoded
}
match infer(&batch_pixels, batch_slots.len()) {
Ok(outputs) => {
// `infer` must return exactly one output per packed image; the zip
// below would otherwise silently leave trailing slots as errors.
debug_assert_eq!(outputs.len(), batch_slots.len());
for (&slot, output) in batch_slots.iter().zip(outputs) {
results[slot] = Ok(output);
}
}
Err(batch_error) => {
log::warn!(
"{model_label} batch inference failed for {} images, falling back to per-image: {batch_error}",
batch_slots.len()
);
for (k, &slot) in batch_slots.iter().enumerate() {
let one = &batch_pixels[k * stride..(k + 1) * stride];
results[slot] = infer(one, 1).and_then(|mut out| {
out.drain(..)
.next()
.ok_or_else(|| anyhow::anyhow!("tagger produced no output for image"))
});
}
}
}
results
}
// ---------------------------------------------------------------------------
// Tagger implementation
// WD tagger implementation
// ---------------------------------------------------------------------------
pub struct WdTagger {
@@ -461,33 +653,34 @@ impl WdTagger {
let session = create_tagger_session(&model_path, acceleration)?;
// Determine the input spatial size from the ONNX model graph.
// WD v3 models use (1, H, W, 3) where H == W, typically 448.
let input_size = {
// Determine the input spatial size (and batch axis, for diagnostics) from
// the ONNX model graph. WD v3 models use (N, H, W, 3) with H == W,
// typically 448, and a dynamic batch dimension (reported as -1/0) which
// batched inference relies on.
let (input_size, batch_axis) = {
let inputs = session.inputs();
let dim = inputs
inputs
.first()
.and_then(|inp| {
if let ort::value::ValueType::Tensor { shape, .. } = inp.dtype() {
shape
.get(1)
.and_then(|&d| if d > 0 { Some(d as usize) } else { None })
let size = shape.get(1).copied().filter(|&d| d > 0).map(|d| d as usize);
Some((size.unwrap_or(448), shape.first().copied()))
} else {
None
}
})
.unwrap_or(448);
dim
.unwrap_or((448, None))
};
let labels = load_labels(&labels_path)?;
log::info!(
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
"WD tagger loaded in {:?} ({} labels, input {}x{}, batch axis {:?}, {:?} acceleration)",
started_at.elapsed(),
labels.len(),
input_size,
input_size,
batch_axis,
acceleration,
);
@@ -499,14 +692,17 @@ impl WdTagger {
})
}
pub fn run(&mut self, image_path: &Path, max_tags: usize) -> Result<TaggerOutput> {
let started_at = Instant::now();
let image_array = preprocess_image(image_path, self.input_size)?;
let batch_size = 1usize;
/// Run `count` already-preprocessed images (contiguous `[count, H, W, 3]`
/// pixels) through the model in one forward pass.
fn infer_batch(
&mut self,
pixels: &[f32],
count: usize,
max_tags: usize,
) -> Result<Vec<TaggerOutput>> {
let input = Tensor::from_array((
[batch_size, self.input_size, self.input_size, 3usize],
image_array.into_boxed_slice(),
[count, self.input_size, self.input_size, 3usize],
pixels.to_vec().into_boxed_slice(),
))
.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -516,23 +712,40 @@ impl WdTagger {
.run(ort::inputs! { input_name.as_str() => input })
.map_err(|error| anyhow::anyhow!("{error}"))?;
let (_, probabilities) = outputs[0]
let (_, probs) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let probs: &[f32] = probabilities;
if probs.len() != self.labels.len() {
let n_labels = self.labels.len();
if probs.len() != count * n_labels {
anyhow::bail!(
"Model output length {} does not match label count {}",
"Model output length {} does not match {} images x {} labels",
probs.len(),
self.labels.len()
count,
n_labels
);
}
// Collect rating scores (category 9) - pick the argmax as the rating.
let rating = self
.labels
// Borrow disjoint fields (not all of `self`) so this doesn't conflict
// with the mutable session borrow `outputs` still holds.
let labels = &self.labels;
let threshold = self.threshold;
Ok(probs
.chunks_exact(n_labels)
.map(|row| Self::tags_from_probs(labels, threshold, row, max_tags))
.collect())
}
/// Convert one image's class probabilities into its rating + sorted tags.
/// Associated (not `&self`) so callers can hold a disjoint session borrow.
fn tags_from_probs(
labels: &[TagEntry],
threshold: f32,
probs: &[f32],
max_tags: usize,
) -> TaggerOutput {
// Rating (category 9): pick the argmax label as the rating.
let rating = labels
.iter()
.zip(probs.iter())
.filter(|(entry, _)| entry.category == RATING_CATEGORY)
@@ -540,14 +753,14 @@ impl WdTagger {
.map(|(entry, _)| entry.name.clone())
.unwrap_or_else(|| "general".to_string());
// Collect general + character tags above threshold, sorted by confidence.
let mut tags: Vec<TagResult> = self
.labels
// General + character tags above threshold, sorted by confidence.
let mut tags: Vec<TagResult> = labels
.iter()
.zip(probs.iter())
.filter(|(entry, prob)| {
(entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY)
&& **prob >= self.threshold
&& **prob >= threshold
&& !ai_tag_filter::is_removed_ai_tag(&entry.name)
})
.map(|(entry, prob)| TagResult {
tag: entry.name.clone(),
@@ -558,16 +771,178 @@ impl WdTagger {
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
tags.truncate(max_tags);
TaggerOutput { tags, rating }
}
}
impl Tagger for WdTagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>> {
if image_paths.is_empty() {
return Vec::new();
}
let input_size = self.input_size;
let preprocessed: Vec<Result<Vec<f32>>> = image_paths
.par_iter()
.map(|path| preprocess_image(path, input_size))
.collect();
assemble_batch(
preprocessed,
input_size * input_size * 3,
"WD tagger",
|pixels, count| self.infer_batch(pixels, count, max_tags),
)
}
fn model_name(&self) -> &'static str {
WD_TAGGER_MODEL_NAME
}
}
// ---------------------------------------------------------------------------
// JoyTag implementation
// JoyTag uses the Danbooru tag schema but generalizes to photographic content
// and is strong on NSFW concepts. It has no rating output, so the explicitness
// rating is derived from its NSFW tags (see `joytag_rating`). Input is NCHW,
// RGB, CLIP-normalized — see `preprocess_joytag`.
// ---------------------------------------------------------------------------
pub struct JoyTagger {
session: Session,
labels: Vec<String>,
threshold: f32,
input_size: usize,
}
impl JoyTagger {
pub fn new(app_data_dir: &Path) -> Result<Self> {
let started_at = Instant::now();
let status = tagger_model_status(app_data_dir);
if !status.ready {
anyhow::bail!(
"JoyTag model is missing {} required file(s): {}",
status.missing_files.len(),
status.missing_files.join(", ")
);
}
let local_dir = model_dir(app_data_dir);
// Shared ONNX runtime DLLs (see WdTagger::new).
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
crate::captioner::ensure_onnx_runtime(&caption_model_dir).map_err(|e| {
anyhow::anyhow!(
"ONNX Runtime not initialised — download the Florence-2 caption model first \
to get the shared runtime DLLs. Original error: {e}"
)
})?;
let acceleration = tagger_acceleration(app_data_dir);
let threshold = joytag_threshold(app_data_dir);
let model_path = local_dir.join("model.onnx");
let labels_path = local_dir.join("top_tags.txt");
let session = create_tagger_session(&model_path, acceleration)?;
// JoyTag uses NCHW (N, 3, H, W); the spatial size lives at axis 2.
let (input_size, batch_axis) = {
let inputs = session.inputs();
inputs
.first()
.and_then(|inp| {
if let ort::value::ValueType::Tensor { shape, .. } = inp.dtype() {
let size = shape.get(2).copied().filter(|&d| d > 0).map(|d| d as usize);
Some((size.unwrap_or(448), shape.first().copied()))
} else {
None
}
})
.unwrap_or((448, None))
};
let labels = load_joytag_labels(&labels_path)?;
log::info!(
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
tags.len(),
self.threshold,
rating,
"JoyTag loaded in {:?} ({} tags, input {}x{}, batch axis {:?}, {:?} acceleration)",
started_at.elapsed(),
image_path.display(),
labels.len(),
input_size,
input_size,
batch_axis,
acceleration,
);
Ok(TaggerOutput { tags, rating })
Ok(Self {
session,
labels,
threshold,
input_size,
})
}
/// Run `count` already-preprocessed images (contiguous `[count, 3, H, W]`
/// pixels) through the model in one forward pass.
fn infer_batch(
&mut self,
pixels: &[f32],
count: usize,
max_tags: usize,
) -> Result<Vec<TaggerOutput>> {
let input = Tensor::from_array((
[count, 3usize, self.input_size, self.input_size],
pixels.to_vec().into_boxed_slice(),
))
.map_err(|error| anyhow::anyhow!("{error}"))?;
let input_name: String = self.session.inputs()[0].name().to_string();
let outputs = self
.session
.run(ort::inputs! { input_name.as_str() => input })
.map_err(|error| anyhow::anyhow!("{error}"))?;
let (_, logits) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let n_labels = self.labels.len();
if logits.len() != count * n_labels {
anyhow::bail!(
"Model output length {} does not match {} images x {} labels",
logits.len(),
count,
n_labels
);
}
let labels = &self.labels;
let threshold = self.threshold;
Ok(logits
.chunks_exact(n_labels)
.map(|row| joytag_tags_from_logits(labels, threshold, row, max_tags))
.collect())
}
}
impl Tagger for JoyTagger {
fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec<Result<TaggerOutput>> {
if image_paths.is_empty() {
return Vec::new();
}
let input_size = self.input_size;
let preprocessed: Vec<Result<Vec<f32>>> = image_paths
.par_iter()
.map(|path| preprocess_joytag(path, input_size))
.collect();
assemble_batch(
preprocessed,
input_size * input_size * 3,
"JoyTag",
|pixels, count| self.infer_batch(pixels, count, max_tags),
)
}
fn model_name(&self) -> &'static str {
JOYTAG_MODEL_NAME
}
}
@@ -586,6 +961,22 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
TaggerAcceleration::Auto | TaggerAcceleration::Directml
);
// Intra-op thread count. For DirectML the matmuls run on the GPU, so a
// single CPU thread for the few CPU-side ops avoids needlessly contending
// with the other workers. The CPU EP, however, is compute-bound and would
// otherwise be pinned to one core (~15x slower than it needs to be), so give
// it most of the logical cores while leaving a couple free for the UI and a
// possible concurrent scan. Tagging is the lowest-priority worker, so when
// it runs the heavier workers are idle. (Auto only lands on CPU when
// DirectML is unavailable — rare on Windows — and stays single-threaded;
// CPU-bound users can select the CPU provider explicitly for the speedup.)
let intra_threads = match acceleration {
TaggerAcceleration::Cpu => std::thread::available_parallelism()
.map(|p| p.get().saturating_sub(2).max(1))
.unwrap_or(2),
TaggerAcceleration::Auto | TaggerAcceleration::Directml => 1,
};
let builder = builder
.with_memory_pattern(!use_directml)
.map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -593,18 +984,18 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul
.with_parallel_execution(false)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let builder = builder
.with_intra_threads(1)
.with_intra_threads(intra_threads)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let mut builder = match acceleration {
TaggerAcceleration::Cpu => {
log::info!("WD tagger: using CPU execution provider");
log::info!("Tagger: using CPU execution provider ({intra_threads} intra-op threads)");
builder
}
TaggerAcceleration::Auto => builder
.with_execution_providers([ep::DirectML::default().build().fail_silently()])
.unwrap_or_else(|error| {
log::info!("WD tagger: DirectML unavailable, falling back to CPU");
log::info!("Tagger: DirectML unavailable, falling back to CPU");
error.recover()
}),
TaggerAcceleration::Directml => builder
@@ -646,12 +1037,134 @@ fn load_labels(path: &Path) -> Result<Vec<TagEntry>> {
}
// ---------------------------------------------------------------------------
// Image preprocessing
// WD tagger expects: (1, H, W, 3) float32, raw [0,255] values, BGR channel
// order, padded to square with white (255,255,255).
// JoyTag: label loading, threshold, rating derivation
// ---------------------------------------------------------------------------
fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
/// JoyTag labels: one tag per line, index == output position. Underscores are
/// replaced with spaces to match the display style used elsewhere.
fn load_joytag_labels(path: &Path) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path)?;
let labels: Vec<String> = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| line.replace('_', " "))
.collect();
if labels.is_empty() {
anyhow::bail!("top_tags.txt is empty or could not be parsed");
}
Ok(labels)
}
/// JoyTag detection threshold. Honours the shared `tagger_threshold` setting if
/// the user has set it, otherwise uses JoyTag's recommended default (0.4).
fn joytag_threshold(app_data_dir: &Path) -> f32 {
match std::fs::read_to_string(app_data_dir.join(TAGGER_THRESHOLD_FILE)) {
Ok(value) => value
.trim()
.parse::<f32>()
.unwrap_or(JOYTAG_DEFAULT_THRESHOLD)
.clamp(0.01, 1.0),
Err(_) => JOYTAG_DEFAULT_THRESHOLD,
}
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
// Explicitness buckets for deriving a rating from JoyTag's tags (highest match
// wins). Names use spaces, since underscores are stripped on load. Tunable.
const JOYTAG_EXPLICIT_TAGS: &[&str] = &[
"sex",
"vaginal",
"anal",
"oral",
"fellatio",
"cunnilingus",
"penis",
"pussy",
"cum",
"ejaculation",
"erection",
"handjob",
"paizuri",
"masturbation",
];
const JOYTAG_QUESTIONABLE_TAGS: &[&str] = &[
"nude",
"completely nude",
"nipples",
"topless",
"bottomless",
"pubic hair",
"areola",
"areolae",
];
const JOYTAG_SENSITIVE_TAGS: &[&str] = &[
"lingerie",
"underwear",
"panties",
"swimsuit",
"bikini",
"cleavage",
"bra",
"midriff",
];
/// Derive an explicitness rating from JoyTag's tags. JoyTag has no rating
/// output, so this maps its NSFW-content tags onto the WD-style buckets.
fn joytag_rating(tags: &[TagResult]) -> String {
let has = |bucket: &[&str]| tags.iter().any(|t| bucket.contains(&t.tag.as_str()));
if has(JOYTAG_EXPLICIT_TAGS) {
"explicit".to_string()
} else if has(JOYTAG_QUESTIONABLE_TAGS) {
"questionable".to_string()
} else if has(JOYTAG_SENSITIVE_TAGS) {
"sensitive".to_string()
} else {
"general".to_string()
}
}
/// Convert one image's JoyTag logits into sorted tags + a derived rating.
fn joytag_tags_from_logits(
labels: &[String],
threshold: f32,
logits: &[f32],
max_tags: usize,
) -> TaggerOutput {
let mut tags: Vec<TagResult> = labels
.iter()
.zip(logits.iter())
.filter_map(|(name, logit)| {
let confidence = sigmoid(*logit);
(confidence >= threshold && !ai_tag_filter::is_removed_ai_tag(name)).then(|| {
TagResult {
tag: name.clone(),
confidence,
}
})
})
.collect();
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
// Derive the rating from all kept tags before truncating to max_tags.
let rating = joytag_rating(&tags);
tags.truncate(max_tags);
TaggerOutput { tags, rating }
}
// ---------------------------------------------------------------------------
// Image preprocessing
// Both taggers pad to square with white and resize to the model input size;
// they differ only in channel order, value range, and tensor layout.
// ---------------------------------------------------------------------------
/// Decode an image, composite any alpha onto white, pad to a centered square,
/// and resize to `target_size`. Shared by both taggers.
fn decode_pad_resize(image_path: &Path, target_size: usize) -> Result<image::RgbImage> {
let image = ImageReader::open(image_path)?.decode()?;
// Composite any alpha channel onto a white background.
@@ -662,22 +1175,25 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0);
let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8();
// Pad to square.
// Pad to a centered square.
let max_dim = width.max(height);
let pad_left = (max_dim - width) / 2;
let pad_top = (max_dim - height) / 2;
let mut square = image::RgbImage::from_pixel(max_dim, max_dim, image::Rgb([255, 255, 255]));
image::imageops::overlay(&mut square, &image_rgb, pad_left as i64, pad_top as i64);
// Resize to model input size.
let resized = image::imageops::resize(
// Resize to model input size (CatmullRom ≈ PIL BICUBIC).
Ok(image::imageops::resize(
&square,
target_size as u32,
target_size as u32,
FilterType::CatmullRom,
);
))
}
// Flatten to (H, W, 3) float32 in BGR order, values in [0, 255].
/// WD tagger input: (N, H, W, 3) float32, raw [0,255] values, BGR order.
fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
let resized = decode_pad_resize(image_path, target_size)?;
let mut pixel_values = vec![0.0f32; target_size * target_size * 3];
for (x, y, pixel) in resized.enumerate_pixels() {
let base = (y as usize * target_size + x as usize) * 3;
@@ -686,6 +1202,21 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
pixel_values[base + 1] = f32::from(pixel[1]); // G
pixel_values[base + 2] = f32::from(pixel[0]); // R
}
Ok(pixel_values)
}
/// JoyTag input: (N, 3, H, W) float32, RGB, CLIP-normalized ((x/255 mean)/std).
fn preprocess_joytag(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
let resized = decode_pad_resize(image_path, target_size)?;
let plane = target_size * target_size;
let mut pixel_values = vec![0.0f32; 3 * plane];
for (x, y, pixel) in resized.enumerate_pixels() {
let idx = y as usize * target_size + x as usize;
// Channel-major (NCHW): R plane, then G, then B.
for c in 0..3 {
pixel_values[c * plane + idx] =
(f32::from(pixel[c]) / 255.0 - JOYTAG_MEAN[c]) / JOYTAG_STD[c];
}
}
Ok(pixel_values)
}
+107
View File
@@ -12,6 +12,9 @@ pub struct GeneratedThumbnail {
pub path: PathBuf,
pub width: Option<i64>,
pub height: Option<i64>,
/// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty
/// when the thumbnail already existed (the color backfill handles those).
pub palette: Vec<(u8, u8, u8, f32)>,
}
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
@@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette: Vec::new(),
});
}
@@ -47,6 +51,12 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
// Sample the dominant-color palette from the resized buffer before encoding.
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
@@ -58,6 +68,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette,
})
}
@@ -166,6 +177,7 @@ pub fn generate_video_thumbnail(
path: out_path,
width: None,
height: None,
palette: Vec::new(),
});
}
@@ -231,6 +243,7 @@ pub fn generate_video_thumbnail(
path: out_path,
width: None,
height: None,
palette: Vec::new(),
});
}
last_error = String::from_utf8_lossy(&output.stderr).to_string();
@@ -241,6 +254,100 @@ pub fn generate_video_thumbnail(
))
}
pub fn generate_avif_thumbnail(
tools: &MediaTools,
image_path: &Path,
cache_dir: &Path,
) -> Result<GeneratedThumbnail> {
let path_str = image_path.to_string_lossy();
let out_path = thumb_path(cache_dir, &path_str);
let original_dimensions = ffprobe_dimensions(tools, image_path);
if out_path.exists() {
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette: Vec::new(),
});
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let output_path = out_path.to_string_lossy().into_owned();
let output = tools
.ffmpeg_command()
.args([
"-y",
"-threads",
"2",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
])
.output()?;
if output.status.success() && out_path.exists() {
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
// sample the palette by reading the JPEG thumbnail we just wrote.
let palette =
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
.unwrap_or_default()
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette,
});
}
Err(anyhow!(
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
String::from_utf8_lossy(&output.stderr)
))
}
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
let output = tools
.ffprobe_command()
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
&image_path.to_string_lossy(),
])
.output();
let Ok(output) = output else {
return (None, None);
};
if !output.status.success() {
return (None, None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parts = stdout.trim().split('x');
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
(width, height)
}
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "jpg")
}
+85
View File
@@ -36,6 +36,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
Ok(())
}
/// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by
/// the "Rebuild semantic index" maintenance action when stored vectors no longer
/// match the active model's dimension (e.g. after switching embedding models),
/// so the columns are rebuilt to the right size before embeddings regenerate.
pub fn rebuild_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
"DROP TABLE IF EXISTS image_vec;
DROP TABLE IF EXISTS caption_vec;",
)?;
migrate(conn)
}
#[allow(dead_code)]
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
@@ -251,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.
/// 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(
conn: &Connection,
folder_id: Option<i64>,
@@ -360,6 +410,41 @@ pub fn search_image_ids_by_embedding_in_folder(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Brute-force cosine search scoped to a single album (membership via
/// `album_images`), ordered by ascending distance. Mirrors the folder-scoped
/// variant for region-based similarity search.
pub fn search_image_ids_by_embedding_in_album(
conn: &Connection,
embedding: &[f32],
album_id: i64,
exclude_image_id: Option<i64>,
limit: usize,
) -> Result<Vec<i64>> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
let exclude_id = exclude_image_id.unwrap_or(-1);
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN album_images ai ON ai.image_id = v.image_id
WHERE ai.album_id = ?2
AND v.image_id != ?3
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
LIMIT ?4",
)?;
let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
#[allow(dead_code)]
pub fn search_caption_ids_by_embedding(
conn: &Connection,
+22 -5
View File
@@ -5,12 +5,15 @@ import { BackgroundTasks } from "./components/BackgroundTasks";
import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud";
import { ExploreView } from "./components/ExploreView";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast";
import { WhatsNewToast } from "./components/WhatsNewToast";
import { WhatsNewModal } from "./components/WhatsNewModal";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
@@ -21,31 +24,42 @@ export default function App() {
const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
const loadAppVersion = useGalleryStore((state) => state.loadAppVersion);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
void loadAppVersion();
void loadWorkerPausesPersist();
void loadFfmpegStatus();
void loadOnboardingCompleted();
// Load the app version first so the What's New toast/modal (which read
// appVersion from the store) have it before the greeting can appear.
void loadAppVersion().then(() => initWhatsNew());
// Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true });
}
loadFolders().then(() => {
loadFolders().then(async () => {
void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
return loadImages(true);
await loadAlbums();
await loadImages(true);
if (import.meta.env.MODE === "ui") {
const { applyMockScenario } = await import("./dev/applyMockScenario");
applyMockScenario();
}
});
let unlisten: (() => void) | undefined;
subscribeToProgress().then((fn) => {
@@ -74,7 +88,7 @@ export default function App() {
) : activeView === "explore" ? (
<>
<BackgroundTasks />
<TagCloud />
<ExploreView />
</>
) : activeView === "duplicates" ? (
<>
@@ -93,7 +107,10 @@ export default function App() {
<Lightbox />
<SettingsModal />
<FolderPickerModal />
<UpdateToast />
<WhatsNewToast />
<WhatsNewModal />
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div>
+114
View File
@@ -0,0 +1,114 @@
// Parses the project CHANGELOG.md (imported raw at build time) into structured
// data so the "What's New" UI can render it nicely instead of dumping markdown.
// Keeping the changelog as the single source of truth means there's no separate
// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see.
import changelogRaw from "../CHANGELOG.md?raw";
export interface ChangelogItem {
/** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */
lead: string | null;
/** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
body: string;
}
export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string;
items: ChangelogItem[];
}
export interface ChangelogEntry {
version: string;
date: string | null;
sections: ChangelogSection[];
}
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/;
// "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
// "- bullet text"
const BULLET = /^[-*]\s+(.*)$/;
// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in.
// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.)
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim();
const match = collapsed.match(LEAD);
if (match) {
return { lead: match[1].trim(), body: match[2].trim() };
}
return { lead: null, body: collapsed };
}
function parseChangelog(raw: string): ChangelogEntry[] {
const lines = raw.split(/\r?\n/);
const entries: ChangelogEntry[] = [];
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let buffer: string[] = [];
const flushItem = () => {
if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" ")));
}
buffer = [];
};
for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING);
if (versionMatch) {
flushItem();
section = null;
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] };
entries.push(entry);
continue;
}
// Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF).
if (!entry) continue;
const sectionMatch = line.match(SECTION_HEADING);
if (sectionMatch) {
flushItem();
section = { title: sectionMatch[1].trim(), items: [] };
entry.sections.push(section);
continue;
}
const bulletMatch = line.match(BULLET);
if (bulletMatch) {
flushItem();
buffer.push(bulletMatch[1]);
continue;
}
if (line.trim() === "") {
// A blank line ends a (possibly wrapped) multi-line bullet.
flushItem();
continue;
}
// Indented continuation of the current wrapped bullet.
if (buffer.length > 0) {
buffer.push(line.trim());
}
}
flushItem();
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }));
}
const ENTRIES = parseChangelog(changelogRaw);
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null;
// Strip leading "v" and any build suffix (e.g. "-ui", "-dev", "-beta.1") so
// dev/UI-lab builds still resolve to the correct changelog entry.
const normalized = version.replace(/^v/, "").replace(/-[a-z].*/i, "");
// Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null;
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
}
+127 -37
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, WorkerKey } from "../store";
import { Tooltip } from "./Tooltip";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail",
@@ -20,6 +22,7 @@ interface Task {
id: number;
name: string;
stages: TaskStage[];
isActive: boolean;
hasFailedEmbeddings: boolean;
hasFailedTagging: boolean;
hasFailedCaptions: boolean;
@@ -30,23 +33,53 @@ interface Task {
snapshot: string;
}
interface FailedEmbeddingItem {
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>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
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>
</Tooltip>
</div>
);
}
export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
@@ -56,27 +89,43 @@ export function BackgroundTasks() {
void loadWorkerStates();
}, [folders, loadWorkerStates]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo(
// Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedEmbeddingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
),
[mediaJobProgress],
);
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
),
[mediaJobProgress],
);
useEffect(() => {
if (!expanded) return;
for (const [folderId, count] of Object.entries(failedCounts)) {
for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) {
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", {
invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
folderId: Number(folderId),
})
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items })))
.then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedCounts]);
for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false;
@@ -110,6 +159,18 @@ export function BackgroundTasks() {
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;
@@ -216,6 +277,7 @@ export function BackgroundTasks() {
id: folder.id,
name: folder.name,
stages,
isActive,
hasFailedEmbeddings,
hasFailedTagging,
hasFailedCaptions,
@@ -227,8 +289,12 @@ export function BackgroundTasks() {
};
})
.filter((t): t is Task => t !== null)
.filter((t) => dismissed[t.id] !== t.snapshot);
}, [folders, indexingProgress, mediaJobProgress, dismissed]);
.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 ? {
@@ -248,6 +314,7 @@ export function BackgroundTasks() {
: null,
failed: false,
}],
isActive: true,
hasFailedEmbeddings: false,
hasFailedTagging: false,
hasFailedCaptions: false,
@@ -302,20 +369,20 @@ export function BackgroundTasks() {
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"
? "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" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
<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 && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<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 ? (
@@ -328,6 +395,7 @@ export function BackgroundTasks() {
</svg>
)}
</button>
</Tooltip>
)}
</span>
);
@@ -355,14 +423,27 @@ export function BackgroundTasks() {
</span>
)}
{/* Retry (failed embeddings only) */}
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && (
{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 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }}
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) */}
@@ -377,15 +458,16 @@ export function BackgroundTasks() {
{/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<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>
</Tooltip>
)}
</div>
@@ -414,7 +496,7 @@ export function BackgroundTasks() {
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"
? "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"
@@ -426,13 +508,13 @@ export function BackgroundTasks() {
</svg>
)}
<span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}>
<span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail}
</span>
{workerKey && (
<Tooltip label={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} anchorToCursor>
<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 ? (
@@ -445,6 +527,7 @@ export function BackgroundTasks() {
</svg>
)}
</button>
</Tooltip>
)}
</span>
);
@@ -465,8 +548,17 @@ export function BackgroundTasks() {
</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 hover:bg-amber-500/20 transition-colors shrink-0"
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);
@@ -474,18 +566,20 @@ export function BackgroundTasks() {
>
Retry
</button>
</div>
)}
{task.id >= 0 && (
<Tooltip label="Dismiss" anchorToCursor>
<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>
</Tooltip>
)}
</div>
@@ -495,22 +589,18 @@ export function BackgroundTasks() {
</p>
)}
{/* Failed embedding file list */}
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && (
{/* 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">
{failedItems[task.id].map((item) => (
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0">
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" 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">
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p>
{item.error && (
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
{failedEmbeddingItems[task.id].map((item) => (
<FailedWorkerItemRow key={item.image_id} item={item} />
))}
</div>
)}
</div>
</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>
)}
+280
View File
@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { BulkTagPopover } from "./bulk/BulkTagPopover";
import { Tooltip } from "./Tooltip"
type Panel = "tag" | "rating" | "album" | "delete" | null;
export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds);
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection);
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite);
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating);
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const [panel, setPanel] = useState<Panel>(null);
const [deleting, setDeleting] = useState(false);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
// Close any open popover when clicking outside the bar.
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Reset transient UI whenever the selection empties.
useEffect(() => {
if (selectedCount === 0) {
setPanel(null);
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) return null;
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
const handleDelete = async () => {
setDeleting(true);
try {
await bulkDeleteSelected();
} finally {
setDeleting(false);
setPanel(null);
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name || creatingAlbum) return;
setCreatingAlbum(true);
try {
const album = await createAlbum(name);
await addToAlbum(album.id, ids);
setNewAlbumName("");
setPanel(null);
} finally {
setCreatingAlbum(false);
}
};
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors";
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`;
const btnActive = `${btn} bg-white/10 text-white`;
return (
<div
ref={barRef}
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()}
>
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? (
<Tooltip label={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}>
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
</Tooltip>
) : null}
</div>
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
Rating
</button>
{panel === "rating" ? (
<div
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 (
<Tooltip label={`Set ${rating} star${rating === 1 ? "" : "s"}`}>
<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);
}}
>
<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>
</Tooltip>
);
})}
<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}
</div>
<Tooltip label="Mark as favorite" followCursor>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)}>
Favorite
</button>
</Tooltip>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
Add to album
</button>
{panel === "album" ? (
<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>
{inAlbumView ? (
<button
className={`${btn} text-amber-300/90 hover:bg-amber-500/10 hover:text-amber-200`}
onClick={() => void removeFromAlbum(selectedAlbumId, ids)}
>
Remove from album
</button>
) : null}
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<Tooltip label="Delete files from disk" followCursor>
<button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")}
disabled={deleting}
>
{deleting ? "Deleting…" : "Delete"}
</button>
</Tooltip>
{panel === "delete" ? (
<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">
<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={() => 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}
</div>
<Tooltip label="Clear selection" followCursor>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
type Rgb = [number, number, number];
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
}
function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
// Collapse the panel when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
return (
<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
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400} anchorToCursor>
<button
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"
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<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}
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="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
</svg>
{isActive ? (
<span
className="h-3 w-3 rounded-full border border-white/30"
style={{ backgroundColor: toHex(colorFilter as Rgb) }}
/>
) : null}
</button>
</Tooltip>
<AnimatePresence initial={false}>
{open ? (
// Right-aligned popover so it never widens the toolbar row or gets
// pushed off-screen on narrow windows. Swatches wrap into a compact
// grid instead of a single long horizontal strip.
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
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"
>
<div className="grid grid-cols-7 gap-1.5">
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
return (
<Tooltip label= {swatch.name} followCursor>
<button
key={swatch.name}
aria-label={`Filter by ${swatch.name}`}
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"
}`}
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. */}
<label
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
</Tooltip>
</div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/6 pt-2 light-theme:border-gray-700/40">
{colorBackfill && colorBackfill.total > 0 ? (
<Tooltip label="Sampling colours from existing thumbnails — colour search fills in as this runs" anchorToCursor>
<span className="text-[10px] text-gray-600">
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
</Tooltip>
) : <span />}
{isActive ? (
<Tooltip label="Clear colour filter" anchorToCursor>
<button
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
>
Clear
</button>
</Tooltip>
) : null}
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+31
View File
@@ -42,6 +42,7 @@ const DEMO_UPDATE_VERSION = "0.2.0";
export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders);
const appVersion = useGalleryStore((state) => state.appVersion);
const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null);
@@ -160,6 +161,18 @@ export function DemoPanel() {
});
};
// --- What's New flow ------------------------------------------------------
// Drives the post-update greeting without needing a real version change. The
// toast + modal pull their content from the bundled changelog for the running
// app version, so the current release's notes are what render.
const showWhatsNewToast = () =>
useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false });
const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null });
const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null });
if (!open) return null;
const injectBtn =
@@ -215,6 +228,24 @@ export function DemoPanel() {
Reset updater state
</button>
</div>
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">What's new</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}>
Show "What's new" toast
</button>
<button className={injectBtn} onClick={openWhatsNewModal}>
Open "What's new" modal
</button>
<button className={neutralBtn} onClick={resetWhatsNew}>
Reset What's New state
</button>
</div>
</div>
);
}
+86 -16
View File
@@ -1,7 +1,9 @@
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
@@ -67,18 +69,18 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
<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;
const src = mediaSrc(image.thumbnail_path);
return (
<Tooltip label={image.path} anchorToCursor>
<button
key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${
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} />
@@ -98,6 +100,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
</Tooltip>
);
})}
</div>
@@ -128,8 +131,20 @@ export function DuplicateFinder() {
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
// Virtualize the group list so a large result set (e.g. thousands of pairs)
// only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: duplicateGroups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 220,
overscan: 4,
});
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
@@ -141,6 +156,7 @@ export function DuplicateFinder() {
const handleDelete = async () => {
setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
@@ -165,7 +181,7 @@ export function DuplicateFinder() {
: null;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
{/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4">
@@ -192,35 +208,71 @@ export function DuplicateFinder() {
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<Tooltip label={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`} anchorToCursor>
<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"
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>
</Tooltip>
)}
{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"
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={handleDelete}
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"
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}
>
@@ -277,11 +329,29 @@ export function DuplicateFinder() {
<p className="text-sm text-white/25">No duplicate files found.</p>
</div>
) : (
<div className="overflow-y-auto px-6 py-5">
<div className="space-y-4">
{duplicateGroups.map((group) => (
<DuplicateGroupCard key={group.file_hash} group={group} />
))}
<div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = duplicateGroups[virtualItem.index];
if (!group) return null;
return (
<div
key={group.file_hash}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16,
}}
>
<DuplicateGroupCard group={group} />
</div>
);
})}
</div>
</div>
)}
File diff suppressed because it is too large Load Diff
+585
View File
@@ -0,0 +1,585 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
function cleanAddressInput(path: string): string {
const trimmed = path.trim();
if (trimmed.length >= 2) {
const first = trimmed[0];
const last = trimmed[trimmed.length - 1];
if ((first === "\"" && last === "\"") || (first === "'" && last === "'")) {
return trimmed.slice(1, -1).trim();
}
}
return trimmed;
}
function friendlyDirectoryError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (/cannot find the path|os error 3|not found|no such file/i.test(message)) {
return "Folder not found. Check the path and try again.";
}
return message;
}
function folderName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, "");
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : path;
}
function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] {
if (!path) return [{ label: "This PC / Home", path: null }];
const separator = path.includes("\\") ? "\\" : "/";
const normalized = path.replace(/[\\/]+$/, "");
const windowsDrive = normalized.match(/^[A-Za-z]:/);
if (windowsDrive) {
const drive = windowsDrive[0] + "\\";
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
let current = drive;
return [
{ label: "This PC", path: null },
{ label: drive, path: drive },
...rest.map((part) => {
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
return { label: part, path: current };
}),
];
}
const parts = normalized.split(/[\\/]+/).filter(Boolean);
let current = separator === "/" ? "" : "";
return [
{ label: "/", path: null },
...parts.map((part) => {
current = `${current}/${part}`;
return { label: part, path: current };
}),
];
}
function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null;
const added = results.filter((result) => result.status === "added").length;
const skipped = results.filter((result) => result.status === "skipped").length;
const failed = results.filter((result) => result.status === "error").length;
return (
<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>
<Tooltip label={entry.path} anchorToCursor className="min-w-0 flex-1">
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={onNavigate}
>
<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>
</Tooltip>
{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}
<Tooltip label={entry.has_children ? "Open folder" : "No subfolders"} anchorToCursor>
<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}
>
<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>
</Tooltip>
</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) => (
<Tooltip key={path} label={path} anchorToCursor block>
<div 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">
<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>
<Tooltip label="Remove from folders to add" anchorToCursor>
<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`}
>
<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>
</Tooltip>
</div>
</Tooltip>
))}
</div>
)}
</div>
</aside>
);
}
export function FolderPickerModal() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const folders = useGalleryStore((state) => state.folders);
const listDirectories = useGalleryStore((state) => state.listDirectories);
const addFolders = useGalleryStore((state) => state.addFolders);
const [listing, setListing] = useState<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [addressDraft, setAddressDraft] = useState("");
const [addressEditing, setAddressEditing] = useState(false);
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 addressInputRef = useRef<HTMLInputElement>(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({
count: listing?.entries.length ?? 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
useEffect(() => {
if (!folderPickerOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return;
setListing(nextListing);
setAddressDraft(nextListing.current ?? "");
setAddressEditing(false);
scrollRef.current?.scrollTo({ top: 0, left: 0 });
})
.catch((loadError) => {
if (cancelled) return;
setListing({ current: currentPath, parent: null, entries: [] });
setError(friendlyDirectoryError(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [currentPath, folderPickerOpen, listDirectories]);
useEffect(() => {
if (!folderPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (addressEditing) {
setAddressDraft(listing?.current ?? "");
setAddressEditing(false);
return;
}
setFolderPickerOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [addressEditing, folderPickerOpen, listing?.current, setFolderPickerOpen]);
useEffect(() => {
if (!addressEditing) return;
requestAnimationFrame(() => {
addressInputRef.current?.focus();
addressInputRef.current?.select();
});
}, [addressEditing]);
useEffect(() => {
if (folderPickerOpen) return;
setCurrentPath(null);
setAddressDraft("");
setAddressEditing(false);
setListing(null);
setStagedPaths([]);
setError(null);
setResults(null);
setAdding(false);
}, [folderPickerOpen]);
if (!folderPickerOpen) return null;
const entries = listing?.entries ?? [];
const addressPath = cleanAddressInput(addressEditing ? addressDraft : (listing?.current ?? ""));
const normalizedAddressPath = addressPath ? normalizePath(addressPath) : "";
const addressAlreadyAdded = normalizedAddressPath ? libraryPaths.has(normalizedAddressPath) : false;
const addressAlreadyStaged = normalizedAddressPath ? stagedSet.has(normalizedAddressPath) : false;
const togglePath = (path: string) => {
const normalized = normalizePath(path);
if (libraryPaths.has(normalized)) return;
setResults(null);
setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized);
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
});
};
const stagePath = (path: string) => {
const cleaned = cleanAddressInput(path);
if (!cleaned) {
setError("Enter a folder path first.");
return;
}
const normalized = normalizePath(cleaned);
if (libraryPaths.has(normalized)) {
setError("That folder is already in your library.");
return;
}
if (stagedSet.has(normalized)) {
setError("That folder is already selected.");
return;
}
setError(null);
setResults(null);
setStagedPaths((current) => [...current, cleaned]);
};
const navigateToAddress = () => {
const cleaned = cleanAddressInput(addressDraft);
setResults(null);
setError(null);
setCurrentPath(cleaned || null);
};
const beginAddressEdit = () => {
setAddressDraft(listing?.current ?? "");
setAddressEditing(true);
};
const removeStagedPath = (path: string) => {
const normalized = normalizePath(path);
setResults(null);
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
};
const clearStagedPaths = () => {
setResults(null);
setStagedPaths([]);
};
const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return;
setAdding(true);
setError(null);
try {
const addResults = await addFolders(stagedPaths);
const failed = addResults.filter((result) => result.status === "error");
setResults(addResults);
if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
setError(failed.map((failure) => failure.data).join("; "));
return;
}
setFolderPickerOpen(false);
} catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError));
} finally {
setAdding(false);
}
};
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setFolderPickerOpen(false)}
>
<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"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<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>
</div>
<Tooltip label="Close folder picker" anchorToCursor>
<button
type="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"
onClick={() => setFolderPickerOpen(false)}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<main className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-4 flex items-center gap-2">
<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"
onClick={() => setCurrentPath(listing?.parent ?? null)}
disabled={!listing?.current}
>
Up
</button>
{addressEditing ? (
<form
className="flex min-w-0 flex-1 items-center gap-2"
onSubmit={(event) => {
event.preventDefault();
navigateToAddress();
}}
>
<label className="sr-only" htmlFor="folder-picker-address">Folder path</label>
<input
ref={addressInputRef}
id="folder-picker-address"
className="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 outline-none transition-colors focus:border-white/25 focus:bg-white/[0.055] 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"
value={addressDraft}
onChange={(event) => {
setAddressDraft(event.target.value);
setResults(null);
}}
placeholder="Paste or type a folder path"
spellCheck={false}
/>
<button
type="submit"
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"
disabled={loading}
>
Go
</button>
</form>
) : (
<div
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"
>
<nav className="flex min-w-0 items-center gap-1 overflow-hidden">
{breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<Tooltip label={crumb.path ?? "Roots"} anchorToCursor>
<button
type="button"
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={(event) => {
event.stopPropagation();
setCurrentPath(crumb.path);
}}
>
{crumb.label}
</button>
</Tooltip>
</span>
))}
</nav>
<button
type="button"
className="min-w-10 flex-1 self-stretch cursor-text rounded px-1"
onClick={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={() => stagePath(addressPath)}
disabled={!addressPath || addressAlreadyAdded || addressAlreadyStaged}
>
Select
</button>
</div>
{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">
{error}
</div>
) : 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">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
) : entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const entry = entries[virtualItem.index];
const normalized = normalizePath(entry.path);
return (
<div
key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<FolderRow
entry={entry}
selected={stagedSet.has(normalized)}
alreadyAdded={libraryPaths.has(normalized)}
onToggle={() => togglePath(entry.path)}
onNavigate={() => setCurrentPath(entry.path)}
/>
</div>
);
})}
</div>
)}
</div>
</main>
<StagedFoldersPanel
stagedPaths={stagedPaths}
onRemove={removeStagedPath}
onClear={clearStagedPaths}
/>
</div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1">
<StatusLine results={results} />
</div>
<div className="flex shrink-0 items-center gap-2">
<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"
onClick={() => setFolderPickerOpen(false)}
>
Cancel
</button>
<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"
onClick={() => void confirmAdd()}
disabled={stagedPaths.length === 0 || adding}
>
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
</button>
</div>
</div>
</footer>
</div>
</div>
);
}
+12 -10
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
/**
* In-view folder scope picker for feature views (Timeline / Explore /
@@ -33,15 +34,15 @@ export function FolderScopeDropdown() {
};
return (
<div ref={ref} className="relative">
<div ref={ref} className="feature-scope-dropdown relative">
<Tooltip label="Change folder scope" anchorToCursor>
<button
onClick={() => setOpen((v) => !v)}
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
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 ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "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"
}`}
title="Change folder scope"
>
<svg className="h-3.5 w-3.5 shrink-0 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
@@ -54,11 +55,12 @@ export function FolderScopeDropdown() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</Tooltip>
{open ? (
<div className="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">
<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={`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" : "text-gray-400 hover:bg-white/5 hover:text-white"
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)}
>
@@ -74,8 +76,8 @@ export function FolderScopeDropdown() {
return (
<button
key={folder.id}
className={`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" : "text-gray-400 hover:bg-white/5 hover:text-white"
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)}
>
+184 -58
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
import { mediaSrc } from "../lib/mediaSrc";
const GAP = 6;
@@ -29,8 +32,7 @@ export function ContextMenu({
}) {
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const canFindSimilar = image.embedding_status === "ready";
return (
@@ -58,9 +60,9 @@ export function ContextMenu({
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={async () => {
onClick={() => {
if (!canFindSimilar) return;
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
@@ -73,11 +75,10 @@ export function ContextMenu({
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<Tooltip key={rating} label={`Set ${rating} star rating`} followCursor>
<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"}`}
@@ -86,18 +87,20 @@ export function ContextMenu({
<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>
</Tooltip>
);
})}
{image.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<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>
</Tooltip>
) : null}
</div>
</div>
@@ -111,28 +114,69 @@ export function ImageTile({
}: {
image: ImageRecord;
onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void;
onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
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)
: image.media_kind === "image" && image.path
? convertFileSrc(image.path)
: null;
const src = mediaSrc(image.thumbnail_path);
return (
<button
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none"
<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" }}
onClick={onClick}
onContextMenu={onContextMenu}
title={image.filename}
>
{/* 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 ? (
<>
@@ -176,6 +220,16 @@ export function ImageTile({
{/* 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" && (
<Tooltip label={image.embedding_error ?? "Embedding failed"} followCursor className="pointer-events-auto">
<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">
<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>
</Tooltip>
)}
{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">
@@ -199,27 +253,12 @@ export function ImageTile({
)}
</div>
{/* Embedding failed badge — top-left */}
{image.embedding_status === "failed" && (
<div
className="absolute top-2 left-2 pointer-events-none"
title={image.embedding_error ?? "Embedding 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">
<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>
</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="absolute bottom-0 left-0 right-0 z-20 p-2.5 translate-y-1 group-hover:translate-y-0 opacity-0 group-hover:opacity-100 transition-all duration-200">
<TruncatedFilename filename={image.filename} />
<div className="mt-1.5 flex items-center justify-between gap-2">
{image.rating > 0 ? (
<div className="flex items-center gap-0.5">
@@ -233,7 +272,7 @@ export function ImageTile({
<span />
)}
<button
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${
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"
@@ -241,7 +280,7 @@ export function ImageTile({
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
}}
disabled={!canFindSimilar}
>
@@ -249,7 +288,39 @@ export function ImageTile({
</button>
</div>
</div>
</button>
</div>
);
}
function TruncatedFilename({ filename }: { filename: string }) {
const textRef = useRef<HTMLParagraphElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useLayoutEffect(() => {
const text = textRef.current;
if (!text) return;
const update = () => {
setIsTruncated(text.scrollWidth > text.clientWidth);
};
update();
const observer = new ResizeObserver(update);
observer.observe(text);
return () => observer.disconnect();
}, [filename]);
const label = (
<p ref={textRef} className="truncate text-[12px] font-medium leading-tight text-white">
{filename}
</p>
);
return (
<Tooltip label={filename} delay={500} block followCursor disabled={!isTruncated}>
{label}
</Tooltip>
);
}
@@ -268,29 +339,62 @@ export function Gallery() {
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const rowCount = Math.ceil(images.length / cols);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 3,
paddingStart: GAP,
});
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
const handleScroll = useCallback(() => {
const element = parentRef.current;
if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const element = parentRef.current;
if (!element) return;
element.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll);
const el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
useEffect(() => {
const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
@@ -308,7 +412,8 @@ export function Gallery() {
}, []);
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div className="relative flex-1 min-h-0">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
@@ -365,15 +470,28 @@ export function Gallery() {
</div>
</div>
) : (
<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
className="grid content-start"
key={virtualRow.key}
style={{
padding: GAP,
position: "absolute",
top: virtualRow.start,
width: "100%",
height: virtualRow.size,
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}}
>
{images.map((image) => (
{rowImages.map((image) => (
<ImageTile
key={image.id}
image={image}
@@ -385,6 +503,9 @@ export function Gallery() {
/>
))}
</div>
);
})}
</div>
)}
{images.length > 0 && loadingImages ? (
@@ -402,5 +523,10 @@ export function Gallery() {
/>
) : null}
</div>
{/* Pinned to the bottom of the gallery viewport outside the scroll
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
);
}
+211 -31
View File
@@ -1,8 +1,11 @@
import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, ImageTag, AiRating } from "../store";
import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@@ -143,15 +146,18 @@ export function Lightbox() {
const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag);
const removeTag = useGalleryStore((state) => state.removeTag);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const getImageExif = useGalleryStore((state) => state.getImageExif);
// Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image.
@@ -163,12 +169,17 @@ export function Lightbox() {
const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(null);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false);
// Region selection state
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
@@ -214,6 +225,7 @@ export function Lightbox() {
useEffect(() => {
setView(IDENTITY_VIEW);
setImageTags([]);
setImageExif(null);
setTagInput("");
setTagsExpanded(false);
setTaggingQueued(false);
@@ -232,6 +244,20 @@ export function Lightbox() {
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// EXIF is read on demand from the file (not stored), so it works on every
// already-indexed image without a reindex. Only meaningful for images.
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== "image") {
setImageExif(null);
return;
}
let cancelled = false;
void getImageExif(selectedImage.id)
.then((exif) => { if (!cancelled) setImageExif(exif); })
.catch(() => { if (!cancelled) setImageExif(null); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
// Reset the queued state once the worker finishes so the button is usable again
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
@@ -360,12 +386,10 @@ export function Lightbox() {
exitRegionMode();
setRegionSearching(true);
const folderId =
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
[isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode],
);
// Build the CSS rect for the selection overlay (viewport-relative)
@@ -377,7 +401,7 @@ export function Lightbox() {
{selectedImage ? (
<motion.div
key="lightbox"
className="fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
@@ -453,12 +477,12 @@ export function Lightbox() {
transition={{ duration: 0.12 }}
>
{selectedImage.media_kind === "video" ? (
<VideoPlayer src={convertFileSrc(selectedImage.path)} />
<VideoPlayer src={mediaSrc(selectedImage.path) ?? ""} />
) : (
<>
<img
ref={imgRef}
src={convertFileSrc(selectedImage.path)}
src={mediaSrc(selectedImage.path) ?? ""}
alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
@@ -495,36 +519,45 @@ export function Lightbox() {
</AnimatePresence>
</div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
<div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
<p className="text-xs text-gray-500">Details</p>
</div>
<div className="flex items-center gap-2">
<Tooltip label= { selectedImage.favorite ? "Remove favorite" : "Add favorite" } followCursor>
<button
className={`rounded-full border p-2 ${selectedImage.favorite ? "border-rose-400/40 bg-rose-500/10 text-rose-300" : "border-white/10 bg-white/5 text-gray-400 hover:text-white"}`}
onClick={() => void updateImageDetails(selectedImage.id, { favorite: !selectedImage.favorite })}
title={selectedImage.favorite ? "Remove favorite" : "Add favorite"}
>
<svg className="h-4 w-4" 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>
</button>
</Tooltip>
<Tooltip label= {canFindSimilar ? "Find similar images" : "Embeddings not ready"} followCursor>
<button
className={`rounded-full border px-3 py-1.5 text-xs ${
className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
}`}
onClick={() => {
if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
void findSimilar(selectedImage.id, selectedImage.folder_id);
}}
disabled={!canFindSimilar}
>
{canFindSimilar ? "Similar" : "Embeddings not ready"}
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" />
</svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
</button>
</Tooltip>
</div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -536,6 +569,7 @@ export function Lightbox() {
{/* Search region button row */}
{canSearchRegion && (
<div className="shrink-0 px-5 pb-3">
<Tooltip label={ regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content" } followCursor>
<button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode
@@ -551,7 +585,6 @@ export function Lightbox() {
setIsDragging(false);
}}
disabled={regionSearching}
title={regionSelectMode ? "Cancel region selection" : "Draw a region on the image to search for similar content"}
>
{regionSearching ? (
<span className="flex items-center justify-center gap-1.5">
@@ -577,21 +610,23 @@ export function Lightbox() {
</span>
)}
</button>
</Tooltip>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
<div>
<div className="grid grid-cols-2 gap-x-6 gap-y-4">
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<Tooltip label={`Set ${rating} star rating`} followCursor delay={750}>
<button
key={rating}
className="rounded-md p-1"
onClick={() => void updateImageDetails(selectedImage.id, { rating })}
title={`Set ${rating} star rating`}
>
<svg
className={`h-5 w-5 ${rating <= selectedImage.rating ? "text-amber-300" : "text-white/20 hover:text-white/50"}`}
@@ -601,18 +636,20 @@ export function Lightbox() {
<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>
</Tooltip>
);
})}
{selectedImage.rating > 0 ? (
<Tooltip label="Remove rating" followCursor>
<button
className="ml-2 rounded-md border border-white/10 p-1.5 text-gray-400 hover:bg-white/5 hover:text-white"
onClick={() => void updateImageDetails(selectedImage.id, { rating: 0 })}
title="Remove rating"
>
<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>
</Tooltip>
) : null}
</div>
</div>
@@ -644,7 +681,7 @@ export function Lightbox() {
</div>
{selectedImage.metadata_error ? (
<div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Metadata</p>
<p className="text-amber-300">{selectedImage.metadata_error}</p>
</div>
@@ -662,18 +699,19 @@ export function Lightbox() {
<p className="text-white">{formatBytes(selectedImage.file_size)}</p>
</div>
<div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Modified</p>
<p className="text-white">{formatDate(selectedImage.modified_at)}</p>
</div>
<div>
<div className="col-span-2">
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Embedding</p>
<p className="text-white">{embeddingLabel(selectedImage.embedding_status, selectedImage.embedding_model)}</p>
{selectedImage.embedding_error ? (
<p className="mt-1 text-xs text-amber-300">{selectedImage.embedding_error}</p>
) : null}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
@@ -685,10 +723,10 @@ export function Lightbox() {
</span>
) : null}
{selectedImage.media_kind === "image" ? (
<Tooltip label={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"} followCursor>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued}
title={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"}
onClick={() => {
setTaggingQueued(true);
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
@@ -696,7 +734,7 @@ export function Lightbox() {
>
{taggingQueued ? "Queued" : "AI tags"}
</button>
) : null}
</Tooltip>) : null}
</div>
</div>
@@ -704,16 +742,21 @@ export function Lightbox() {
<>
<div className="flex flex-wrap gap-1.5">
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
<span
<Tooltip
key={t.id}
label={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : ""}
followCursor
disabled={t.source !== "ai" || t.confidence === null}
>
<span
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
t.source === "ai"
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
: "border-white/10 bg-white/5 text-gray-300"
}`}
title={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : undefined}
>
{t.tag}
<Tooltip label="Remove tag" followCursor>
<button
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
onClick={() => {
@@ -721,13 +764,14 @@ export function Lightbox() {
setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
);
}}
title="Remove tag"
>
<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>
</Tooltip>
</span>
</Tooltip>
))}
</div>
{imageTags.length > 8 && (
@@ -779,8 +823,144 @@ export function Lightbox() {
</form>
</div>
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((v) => !v); setAlbumAddedTo(null); }}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 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 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
if (albumAdding) return;
setAlbumAdding(true);
void addToAlbum(album.id, [selectedImage.id])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
disabled={albumAdding}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</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-1.5"
onSubmit={(e) => {
e.preventDefault();
const name = newAlbumName.trim();
if (!name || albumAdding) return;
setAlbumAdding(true);
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id);
setNewAlbumName("");
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
>
<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={(e) => setNewAlbumName(e.target.value)}
disabled={albumAdding}
/>
<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={albumAdding || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{imageExif &&
(imageExif.make ||
imageExif.model ||
imageExif.lens ||
imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null)) ? (
<div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<Tooltip label="Open location in your browser" anchorToCursor>
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
onClick={() =>
void invoke("open_map_location", {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</Tooltip>
) : null}
</div>
</div>
) : null}
<div>
<div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
<Tooltip label="Reveal in Explorer" anchorToCursor>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<svg className="h-3.5 w-3.5" 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>
</Tooltip>
</div>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div>
</div>
@@ -793,7 +973,7 @@ export function Lightbox() {
</div>
<button
className="absolute right-80 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20"
className="absolute right-72 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 lg:right-80"
disabled={currentIndex >= images.length - 1 || regionSelectMode}
onClick={(event) => {
event.stopPropagation();
+3 -7
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
type MenuKey = "library" | "view" | "filter";
@@ -72,7 +71,7 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const addFolder = useGalleryStore((state) => state.addFolder);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const reindexFolder = useGalleryStore((state) => state.reindexFolder);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
@@ -93,11 +92,8 @@ export function MenuBar() {
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, []);
const handleAddFolder = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
}
const handleAddFolder = () => {
setFolderPickerOpen(true);
setOpenMenu(null);
};
+331 -33
View File
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog";
import { Tooltip } from "./Tooltip";
type SettingsSection = "workspace" | "general";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "general", label: "General", detail: "App data and diagnostics" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
];
function formatBytesShort(bytes: number): string {
@@ -18,9 +21,9 @@ function formatBytesShort(bytes: number): string {
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className =
tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: tone === "busy"
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"
: "border-white/10 bg-white/[0.04] text-gray-500";
return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
@@ -88,8 +91,8 @@ function ScopeButton({ scope, current, onSelect, children }: {
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(scope)}
>
@@ -98,6 +101,44 @@ function ScopeButton({ scope, current, onSelect, children }: {
);
}
// Display metadata for each selectable tagging model.
const TAGGER_MODELS: Record<TaggerModel, { name: string; tab: string; description: string }> = {
wd: {
name: "WD SwinV2 Tagger v3",
tab: "WD (anime)",
description:
"Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.",
},
joytag: {
name: "JoyTag",
tab: "JoyTag (general)",
description:
"Booru-schema tagger that also handles photographic content and is strong on NSFW concepts. The explicitness rating is derived from its tags.",
},
};
function TaggerModelButton({ model, current, onSelect, children }: {
model: TaggerModel;
current: TaggerModel;
onSelect: (model: TaggerModel) => void;
children: React.ReactNode;
}) {
const active = model === current;
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(model)}
>
{children}
</button>
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration;
current: TaggerAcceleration;
@@ -110,8 +151,8 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => onSelect(acceleration)}
>
@@ -121,12 +162,16 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
const [activeSection, setActiveSection] = useState<SettingsSection>("general");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
const [taggerResetting, setTaggerResetting] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
const [taggerModelSwitchError, setTaggerModelSwitchError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
@@ -137,6 +182,8 @@ export function SettingsModal() {
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [rebuildingIndex, setRebuildingIndex] = useState(false);
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
@@ -169,6 +216,9 @@ export function SettingsModal() {
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const taggerModel = useGalleryStore((state) => state.taggerModel);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const setTaggerModel = useGalleryStore((state) => state.setTaggerModel);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
@@ -178,24 +228,40 @@ export function SettingsModal() {
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist);
const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion);
const buildVariant = useGalleryStore((state) => state.buildVariant);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateProgress = useGalleryStore((state) => state.updateProgress);
const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const openTagManager = useGalleryStore((state) => state.openTagManager);
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
useEffect(() => {
if (!settingsOpen) return;
void loadTaggerModelStatus();
void loadTaggerModel();
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
@@ -207,7 +273,7 @@ export function SettingsModal() {
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => {
if (!settingsOpen || activeSection !== "general") return;
@@ -279,6 +345,7 @@ export function SettingsModal() {
setTaggerClearing(true);
}
setTaggerQueueStatus(null);
setTaggerResetConfirming(false);
void perform
.then((count) => {
@@ -306,10 +373,49 @@ export function SettingsModal() {
});
};
const runResetAiTags = () => {
if (!taggerResetConfirming) {
setTaggerResetConfirming(true);
setTaggerQueueStatus(
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`,
);
return;
}
const selectedIds = taggingQueueFolderIds;
const perform =
taggingQueueScope === "all"
? resetAiTags(null)
: selectedIds.length > 0
? resetAiTagsForFolders(selectedIds)
: Promise.resolve(0);
setTaggerResetting(true);
setTaggerQueueStatus(null);
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before resetting AI tags.");
return;
}
setTaggerQueueStatus(
count === 0
? "No AI tag data found for the current target."
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerResetting(false);
setTaggerResetConfirming(false);
});
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60"
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
>
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
@@ -333,15 +439,16 @@ export function SettingsModal() {
</div>
</aside>
<Tooltip label="Close settings" anchorToCursor className="absolute right-4 top-4 z-10">
<button
className="absolute right-4 top-4 z-10 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={() => setSettingsOpen(false)}
title="Close settings"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
<main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8">
@@ -351,10 +458,39 @@ export function SettingsModal() {
{activeSection === "workspace" ? (
<div className="mt-8 space-y-9">
<SettingsGroup title="Model">
<SettingsItem label="Tagging model" description="Choose which model generates tags. JoyTag suits photos and NSFW; WD is anime-focused. Switching may require a one-time download.">
<div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["wd", "joytag"] as const).map((model) => (
<TaggerModelButton
key={model}
model={model}
current={taggerModel}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
setTaggerModelSwitching(true);
setTaggerModelSwitchError(null);
void setTaggerModel(nextModel)
.catch((error: unknown) => setTaggerModelSwitchError(String(error)))
.finally(() => setTaggerModelSwitching(false));
}}
>
{TAGGER_MODELS[model].tab}
</TaggerModelButton>
))}
</div>
{taggerModelSwitchError ? (
<p className="text-[11px] text-amber-300">{taggerModelSwitchError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}</p>
)}
</div>
</SettingsItem>
<SettingsItem
label={
<>
WD SwinV2 Tagger v3{" "}
{TAGGER_MODELS[taggerModel].name}{" "}
<span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
@@ -362,20 +498,20 @@ export function SettingsModal() {
</span>
</>
}
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
description={TAGGER_MODELS[taggerModel].description}
>
<div className="flex items-center gap-2">
{taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
@@ -384,7 +520,7 @@ export function SettingsModal() {
</>
) : (
<button
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
@@ -397,7 +533,7 @@ export function SettingsModal() {
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability.">
<div className="flex flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
@@ -455,7 +591,7 @@ export function SettingsModal() {
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}</p>
)}
</div>
</SettingsItem>
@@ -519,7 +655,7 @@ export function SettingsModal() {
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs.">
<SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] p-0.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
@@ -533,14 +669,14 @@ export function SettingsModal() {
</div>
<div className="flex gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0}
>
Select all
</button>
<button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed 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={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
>
@@ -579,32 +715,115 @@ export function SettingsModal() {
<SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes.">
<div className="flex items-center gap-2">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
<button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
taggerResetConfirming
? "border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25 light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 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={runResetAiTags}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"}
</button>
{taggerResetConfirming ? (
<button
className="rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => {
setTaggerResetConfirming(false);
setTaggerQueueStatus(null);
}}
disabled={taggerResetting}
>
Cancel
</button>
) : null}
</div>
</SettingsItem>
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsGroup>
<SettingsGroup title="Tag library" description="Review and clean up the tags across your library.">
<SettingsItem label="Manage tags" description="Open the tag manager in Explore to search, rename, and delete tags.">
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white 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={openTagManager}
>
Open tag manager
</button>
</SettingsItem>
</SettingsGroup>
</div>
) : (
<div className="mt-8 space-y-9">
<SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown
value={theme}
onChange={(value) => setTheme(value as AppTheme)}
ariaLabel="App theme"
options={[
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
]}
/>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Video playback">
<SettingsItem
label="Autoplay in lightbox"
description="Start playing videos automatically when opened in the lightbox."
>
<button
role="switch"
aria-checked={lightboxAutoplay}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Start muted"
description="Open videos with their audio muted — unmute from the player controls."
>
<button
role="switch"
aria-checked={lightboxAutoMute}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Updates">
<SettingsItem
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}>
{buildVariant === "cuda" ? "CUDA" : "CPU"}
</StatusPill>
) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
@@ -616,7 +835,24 @@ export function SettingsModal() {
updateStatus === "error" ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? (
"Downloading update — the app will restart when it finishes."
<span className="block">
<span className="text-gray-400">
{updateStatus === "installing"
? "Installing update…"
: updateProgress !== null
? `Downloading update — ${Math.round(updateProgress * 100)}%`
: "Downloading update…"}
</span>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10">
<span
className={`block h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : ""
}`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
/>
</span>
<span className="mt-1 block text-gray-600">The app will restart when it finishes.</span>
</span>
) : (
"Updates are checked quietly at launch and installed only when you choose."
)
@@ -631,7 +867,7 @@ export function SettingsModal() {
</button>
) : (
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void checkForUpdates()}
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
>
@@ -639,6 +875,19 @@ export function SettingsModal() {
</button>
)}
</SettingsItem>
{getChangelogForVersion(appVersion) ? (
<SettingsItem
label="What's new"
description={`See what changed in Phokus v${appVersion}.`}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white 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={openWhatsNew}
>
View changes
</button>
</SettingsItem>
) : null}
</SettingsGroup>
<SettingsGroup title="Setup">
@@ -648,7 +897,7 @@ export function SettingsModal() {
description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white 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={() => {
setSettingsOpen(false);
openOnboarding();
@@ -665,7 +914,7 @@ export function SettingsModal() {
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
@@ -688,6 +937,19 @@ export function SettingsModal() {
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Keep background pauses after restart"
description="When enabled, folders you pause from the sidebar or background bar stay paused the next time Phokus opens."
>
<button
role="switch"
aria-checked={workerPausesPersist}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setWorkerPausesPersist(!workerPausesPersist)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Maintenance">
@@ -730,7 +992,7 @@ export function SettingsModal() {
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
@@ -748,6 +1010,42 @@ export function SettingsModal() {
</button>
</SettingsItem>
<SettingsItem
label="Rebuild semantic index"
description={
<>
<span>
Recreates the visual-embedding index and re-embeds every image in the
background. Use this if semantic or similar-image search reports a
dimension-mismatch error (for example after experimenting with a
different embedding model).
</span>
{rebuildIndexResult !== null ? (
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
) : null}
</>
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setRebuildingIndex(true);
setRebuildIndexResult(null);
void rebuildSemanticIndex()
.then((count) =>
setRebuildIndexResult(
`Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`,
),
)
.catch((error) => setRebuildIndexResult(String(error)))
.finally(() => setRebuildingIndex(false));
}}
disabled={rebuildingIndex}
>
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
</button>
</SettingsItem>
<SettingsItem
label="Thumbnail cache"
description={
@@ -791,7 +1089,7 @@ export function SettingsModal() {
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
+736 -17
View File
@@ -1,6 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
import { mediaSrc } from "../lib/mediaSrc";
import { Tooltip } from "./Tooltip";
interface ContextMenuState {
folderId: number;
@@ -8,6 +12,9 @@ interface ContextMenuState {
y: number;
}
type LibrarySort = "az" | "za" | "custom";
const LIBRARY_SORT_KEY = "phokus-library-sort";
function FolderContextMenu({
menu,
folder,
@@ -82,11 +89,22 @@ function FolderItem({
folder,
selected,
progress,
customOrdering,
dragging,
onDragStart,
onDragEnd,
onKeyboardMove,
}: {
folder: Folder;
selected: boolean;
progress: IndexProgress | undefined;
customOrdering: boolean;
dragging: boolean;
onDragStart: (pointerY: number) => void;
onDragEnd: () => void;
onKeyboardMove: (direction: -1 | 1) => void;
}) {
const dragControls = useDragControls();
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
@@ -141,16 +159,58 @@ function FolderItem({
};
return (
<>
<Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
} ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
>
{customOrdering ? (
<Tooltip label={`Drag to reorder ${folder.name}`} followCursor delay={1000}>
<button
type="button"
aria-label={`Reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{isMissing ? (
<span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -215,9 +275,9 @@ function FolderItem({
</div>
) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Tooltip label="Reindex" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors"
title="Reindex"
onClick={(e) => { e.stopPropagation(); void reindexFolder(folder.id); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -225,9 +285,10 @@ function FolderItem({
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</Tooltip>
<Tooltip label="Remove from app" anchorToCursor>
<button
className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Remove from app"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -235,6 +296,7 @@ function FolderItem({
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
)
)}
@@ -278,40 +340,514 @@ function FolderItem({
onRemove={() => setConfirmingRemoval(true)}
/>
)}
</>
</Reorder.Item>
);
}
function AlbumContextMenu({
x,
y,
onClose,
onRename,
onDelete,
}: {
x: number;
y: number;
onClose: () => void;
onRename: () => void;
onDelete: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const item = (label: string, onClick: () => void, danger = false) => (
<button
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
onClick();
onClose();
}}
>
{label}
</button>
);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ left: x, top: y }}
>
{item("Rename", onRename)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Delete album", onDelete, true)}
</div>
);
}
function AlbumItem({
album,
manageMode = false,
selectedForManage = false,
onToggleManage,
reorderable = false,
onDragStart,
onDragEnd,
}: {
album: Album;
manageMode?: boolean;
selectedForManage?: boolean;
onToggleManage?: () => void;
reorderable?: boolean;
onDragStart?: () => void;
onDragEnd?: () => void;
}) {
const dragControls = useDragControls();
const viewAlbum = useGalleryStore((state) => state.viewAlbum);
const renameAlbum = useGalleryStore((state) => state.renameAlbum);
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id;
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(album.name);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(album.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, album.name]);
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== album.name) {
await renameAlbum(album.id, trimmed);
}
setRenaming(false);
};
const cover = mediaSrc(album.cover_thumbnail_path);
const row = (
<div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
if (manageMode) {
onToggleManage?.();
} else if (!renaming) {
viewAlbum(album.id);
}
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (renaming || (e.key !== "Enter" && e.key !== " ")) return;
e.preventDefault();
if (manageMode) {
onToggleManage?.();
} else {
viewAlbum(album.id);
}
}}
onContextMenu={(e) => {
if (manageMode) return;
e.preventDefault();
e.stopPropagation();
setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) });
}}
>
{/* Manage-mode selection checkbox */}
{manageMode ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<Tooltip label="Drag to reorder" anchorToCursor>
<button
type="button"
aria-label={`Reorder ${album.name}`}
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
</Tooltip>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
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>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
ref={renameInputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setRenaming(false);
}}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
onClick={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
>
Confirm
</button>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : null}
{menu ? (
<AlbumContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onRename={() => setRenaming(true)}
onDelete={() => setConfirmingRemoval(true)}
/>
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
return row;
}
export function Sidebar() {
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const addFolder = useGalleryStore((state) => state.addFolder);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const selectFolder = useGalleryStore((state) => state.selectFolder);
const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView);
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
const albums = useGalleryStore((state) => state.albums);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [createAlbumPending, setCreateAlbumPending] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const newAlbumInputRef = useRef<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set());
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false);
const [orderedAlbums, setOrderedAlbums] = useState(albums);
const orderedAlbumsRef = useRef(albums);
const [draggingAlbum, setDraggingAlbum] = useState(false);
const handleAddFolder = async () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (selected && typeof selected === "string") {
await addFolder(selected);
// Keep the local drag order in sync with the store except mid-drag, so a
// background album refresh doesn't yank the row out from under the pointer.
useEffect(() => {
if (draggingAlbum) return;
setOrderedAlbums(albums);
orderedAlbumsRef.current = albums;
}, [albums, draggingAlbum]);
const handleAlbumReorder = (ids: number[]) => {
const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album]));
const next = ids
.map((id) => byId.get(id))
.filter((album): album is Album => album !== undefined);
orderedAlbumsRef.current = next;
setOrderedAlbums(next);
};
const finishAlbumReorder = () => {
setDraggingAlbum(false);
const nextIds = orderedAlbumsRef.current.map((album) => album.id);
// Read live store order (not the render-time closure) in case albums changed.
const currentIds = useGalleryStore.getState().albums.map((album) => album.id);
const snapshotIds = albums.map((album) => album.id);
if (
snapshotIds.length !== currentIds.length ||
snapshotIds.some((id, index) => id !== currentIds[index])
) {
orderedAlbumsRef.current = useGalleryStore.getState().albums;
setOrderedAlbums(orderedAlbumsRef.current);
return;
}
if (
nextIds.length !== currentIds.length ||
nextIds.some((id, index) => id !== currentIds[index])
) {
void reorderAlbums(nextIds);
}
};
const [librarySort, setLibrarySortState] = useState<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
});
const [customFolders, setCustomFolders] = useState(folders);
const [draggedFolderId, setDraggedFolderId] = useState<number | null>(null);
const folderListRef = useRef<HTMLDivElement>(null);
const customFoldersRef = useRef(folders);
const pointerYRef = useRef(0);
const autoScrollFrameRef = useRef<number | null>(null);
const keyboardPersistRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current);
},
[],
);
useEffect(() => {
if (draggedFolderId !== null) return;
setCustomFolders(folders);
customFoldersRef.current = folders;
}, [folders, draggedFolderId]);
useEffect(() => {
if (draggedFolderId === null) return;
const handlePointerMove = (event: PointerEvent) => {
pointerYRef.current = event.clientY;
};
const autoScroll = () => {
const list = folderListRef.current;
if (list) {
const rect = list.getBoundingClientRect();
const edgeSize = Math.min(64, rect.height * 0.2);
const topDistance = pointerYRef.current - rect.top;
const bottomDistance = rect.bottom - pointerYRef.current;
let velocity = 0;
if (topDistance < edgeSize) {
velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14;
} else if (bottomDistance < edgeSize) {
velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14;
}
if (velocity !== 0) list.scrollTop += velocity;
}
autoScrollFrameRef.current = requestAnimationFrame(autoScroll);
};
window.addEventListener("pointermove", handlePointerMove, { passive: true });
autoScrollFrameRef.current = requestAnimationFrame(autoScroll);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current);
autoScrollFrameRef.current = null;
};
}, [draggedFolderId]);
const displayedFolders = useMemo(() => {
if (librarySort === "custom") return customFolders;
return [...folders].sort((a, b) => {
const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
return librarySort === "az" ? result : -result;
});
}, [customFolders, folders, librarySort]);
const setLibrarySort = (sort: LibrarySort) => {
window.localStorage.setItem(LIBRARY_SORT_KEY, sort);
setLibrarySortState(sort);
};
const handleReorder = (orderedIds: number[]) => {
const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder]));
const next = orderedIds
.map((id) => byId.get(id))
.filter((folder): folder is Folder => folder !== undefined);
customFoldersRef.current = next;
setCustomFolders(next);
};
const finishReorder = () => {
const nextIds = customFoldersRef.current.map((folder) => folder.id);
setDraggedFolderId(null);
const currentIds = folders.map((folder) => folder.id);
if (nextIds.some((id, index) => id !== currentIds[index])) {
void reorderFolders(nextIds);
}
};
const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => {
const current = customFoldersRef.current;
const currentIndex = current.findIndex((folder) => folder.id === folderId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return;
const next = [...current];
[next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]];
customFoldersRef.current = next;
setCustomFolders(next);
// Debounce the DB write so a held arrow key doesn't fire one per keystroke;
// the local order updates immediately, only the persist waits to settle.
if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current);
keyboardPersistRef.current = setTimeout(() => {
keyboardPersistRef.current = null;
void reorderFolders(customFoldersRef.current.map((folder) => folder.id));
}, 400);
};
const handleAddFolder = () => {
setFolderPickerOpen(true);
};
const startCreatingAlbum = () => {
setCreatingAlbum(true);
setNewAlbumName("");
setTimeout(() => newAlbumInputRef.current?.focus(), 0);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name) {
setCreatingAlbum(false);
return;
}
if (createAlbumPending) return;
setCreateAlbumPending(true);
try {
const album = await createAlbum(name);
setNewAlbumName("");
setCreatingAlbum(false);
useGalleryStore.getState().viewAlbum(album.id);
} finally {
setCreateAlbumPending(false);
}
};
const exitManageAlbums = () => {
setManageAlbums(false);
setManageSelectedIds(new Set());
setConfirmingAlbumDelete(false);
};
const toggleManageSelected = (albumId: number) => {
setManageSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(albumId)) next.delete(albumId);
else next.add(albumId);
return next;
});
setConfirmingAlbumDelete(false);
};
const handleDeleteSelectedAlbums = async () => {
const ids = Array.from(manageSelectedIds);
if (ids.length === 0) return;
await deleteAlbums(ids);
exitManageAlbums();
};
return (
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]">
<aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
<Tooltip label="Add Media Folder" anchorToCursor>
<button
onClick={handleAddFolder}
className="p-1.5 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-white/8 transition-colors"
title="Add Media Folder"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</Tooltip>
</div>
{/* Nav */}
@@ -387,27 +923,210 @@ export function Sidebar() {
{/* Section label */}
{folders.length > 0 && (
<div className="px-5 pt-3 pb-1">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<ThemedDropdown
value={librarySort}
onChange={(value) => setLibrarySort(value as LibrarySort)}
ariaLabel="Library order"
compact
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div>
)}
{/* Folder list */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0">
<Reorder.Group
ref={folderListRef}
as="div"
axis="y"
values={displayedFolders.map((folder) => folder.id)}
onReorder={librarySort === "custom" ? handleReorder : () => {}}
layoutScroll
className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0"
>
{folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started
</p>
) : (
folders.map((folder) => (
displayedFolders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]}
customOrdering={librarySort === "custom"}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/>
))
)}
</Reorder.Group>
{/* Albums a visually distinct block, separated from Libraries by a
heavier divider and given cover-thumbnail rows so the two never
read as one list. */}
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<Tooltip label="Manage albums">
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
</Tooltip>
) : null}
<Tooltip label="New album">
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</Tooltip>
</div>
)}
</div>
{/* Manage action row */}
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div>
</aside>
);
-379
View File
@@ -1,379 +0,0 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const ACCENTS = [
"#60a5fa",
"#c084fc",
"#4ade80",
"#fbbf24",
"#f472b4",
"#2dd4bf",
"#fb923c",
"#a78bfa",
"#34d399",
"#f87171",
];
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;
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;
// Spread ellipse shrinks slightly to leave room for card half-widths at the edges
const spreadX = containerW * 0.42;
const spreadY = containerH * 0.36;
const n = entries.length;
// 1. Build initial positions using phyllotaxis spiral
const nodes: PlacedNode[] = entries.map((entry, i) => {
const ratio = Math.max(entry.count / maxCount, 0.08);
// Cards scale from 110px to 230px wide; height is 3/4 of width
const w = 110 + Math.sqrt(ratio) * 120;
const h = w * 0.75;
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,
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. Iterative overlap resolution — no physics, just push apart
const PAD = 24;
for (let iter = 0; iter < 80; 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
if (overlapX < overlapY) {
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1);
nb.x += push;
na.x -= push;
} else {
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1);
nb.y += push;
na.y -= push;
}
}
// Pull gently back toward anchor to prevent runaway drift
na.x += (cx + Math.cos(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadX - na.x) * 0.05;
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05;
}
}
// 3. Clamp so cards never poke outside the container
return nodes.map((node) => ({
...node,
x: Math.min(Math.max(node.x, node.w / 2 + 16), containerW - node.w / 2 - 16),
y: Math.min(Math.max(node.y, node.h / 2 + 16), containerH - node.h / 2 - 16),
}));
}
function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: number[]) => void }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { w, h, accent } = node;
return (
<motion.button
className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] 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 }}
initial={{ opacity: 0, scale: 0.75 }}
animate={{
opacity: 1,
scale: 1,
x: [0, node.driftX, 0],
y: [0, node.driftY, 0],
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed],
}}
transition={{
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 },
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" },
}}
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} 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}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)}
<div className="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="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="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div>
<span
className="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 ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px
const accent = ACCENTS[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7;
return (
<motion.button
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 0.4 + ratio * 0.6, 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="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)}
title={`${entry.tag}${entry.count.toLocaleString()} images`}
>
<span
className="font-medium leading-none"
style={{ color: ratio > 0.55 ? accent : "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>
);
}
function Spinner() {
return (
<motion.div
className="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 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 min-h-0 flex-1 overflow-hidden">
<div className="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} />
))}
</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 selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
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="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 */}
<div className="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="text-[15px] font-semibold text-white">Explore</h2>
<p className="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">
<FolderScopeDropdown />
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`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={`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="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="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} />
) : (
/* 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
@@ -0,0 +1,106 @@
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>
);
}
+187 -29
View File
@@ -2,9 +2,12 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery";
import { Tooltip } from "./Tooltip";
const GAP = 6;
const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
interface TimelineGroup {
key: string;
@@ -12,6 +15,23 @@ interface TimelineGroup {
images: ImageRecord[];
}
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
groupIndex: number;
}
interface ScrubberYear {
year: string;
firstGroupIndex: number;
months: ScrubberMonth[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
@@ -44,6 +64,27 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] {
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] {
const byYear = new Map<string, ScrubberYear>();
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.key === "unknown") continue;
const year = group.key.substring(0, 4);
const monthNum = Number(group.key.substring(5, 7));
if (!byYear.has(year)) {
byYear.set(year, { year, firstGroupIndex: i, months: [] });
}
byYear.get(year)!.months.push({
monthNum,
label: MONTH_SHORT[monthNum - 1] ?? "",
groupIndex: i,
});
}
// Keep insertion order so the scrubber runs the same direction as the scrolled
// content (oldest at top with taken_asc), keeping the active highlight aligned.
return Array.from(byYear.values());
}
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
@@ -55,13 +96,15 @@ export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
image: ImageRecord;
} | null>(null);
// Measure container width before first paint to avoid a single-column flash.
// parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
@@ -74,35 +117,59 @@ export function Timeline() {
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
// Show as soon as there's more than one month to jump between — not gated on
// a full year. With taken_asc sort the loaded set is oldest-first, so this
// reflects whatever range is currently loaded.
const showScrubber = groups.length > 1;
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const groups = useMemo(() => groupImages(images), [images]);
// Flatten the month groups into a single list of fixed-height rows — one
// header row per group, then one tile-row per `cols` images. This lets the
// virtualizer render only the on-screen rows, exactly like the Gallery.
// Previously each *group* was one virtual item that rendered ALL of its
// images, so scrolling into a busy month mounted thousands of tiles at once.
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
const rows: TimelineRow[] = [];
const rowToGroupIndex: number[] = [];
const groupFirstRow: number[] = [];
groups.forEach((group, groupIndex) => {
groupFirstRow[groupIndex] = rows.length;
rows.push({ type: "header", group });
rowToGroupIndex.push(groupIndex);
for (let i = 0; i < group.images.length; i += cols) {
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
rowToGroupIndex.push(groupIndex);
}
});
return { rows, rowToGroupIndex, groupFirstRow };
}, [groups, cols]);
// estimateSize must be exact so virtualizer positions groups correctly.
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
// GAP acts as spacing between this group and the next header.
const estimateSize = useCallback(
(index: number): number => {
const group = groups[index];
if (!group) return HEADER_HEIGHT;
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
(index: number): number =>
rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
[rows, tileSize],
);
const virtualizer = useVirtualizer({
count: groups.length,
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 2,
overscan: 6,
paddingStart: GAP,
});
// Re-measure all items when cols changes so virtualizer positions stay accurate
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own).
// Refs so the scroll handler can read the latest mappings without re-binding.
const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
@@ -110,12 +177,25 @@ export function Timeline() {
const handleScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
// Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages();
}
}, [images.length, loadMoreImages, loadingImages, totalImages]);
}, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => {
const el = parentRef.current;
@@ -140,10 +220,22 @@ export function Timeline() {
};
}, []);
const scrollToGroup = useCallback(
(groupIndex: number) => {
const row = groupFirstRowRef.current[groupIndex] ?? 0;
virtualizer.scrollToIndex(row, { align: "start" });
},
[virtualizer],
);
return (
// Outer flex-row: fills remaining height in <main>'s flex-col, then
// splits horizontally between the scroll area and the scrubber.
<div className="flex flex-1 min-h-0 bg-gray-950">
{/* Scroll container — flex-1 takes all width except the scrubber */}
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
>
{images.length === 0 && loadingImages ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
@@ -180,8 +272,8 @@ export function Timeline() {
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index];
if (!group) return null;
const row = rows[virtualItem.index];
if (!row) return null;
return (
<div
key={virtualItem.key}
@@ -192,22 +284,20 @@ export function Timeline() {
height: virtualItem.size,
}}
>
{/* Group header */}
{row.type === "header" ? (
<div
className="flex items-center gap-3 px-4"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-sm font-semibold text-white/80 shrink-0">
{group.label}
{row.group.label}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{group.images.length}
{row.group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
</div>
{/* Image grid paddingBottom:GAP gives the gap below the last row,
matching the row-to-row gap and making estimateSize exact. */}
) : (
<div
style={{
display: "grid",
@@ -216,9 +306,10 @@ export function Timeline() {
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}}
>
{group.images.map((image) => (
{row.images.map((image) => (
<ImageTile
key={image.id}
image={image}
@@ -230,6 +321,7 @@ export function Timeline() {
/>
))}
</div>
)}
</div>
);
})}
@@ -241,6 +333,24 @@ export function Timeline() {
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
) : null}
</div>
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
{showScrubber ? (
<div
className="overflow-y-auto border-l border-white/[0.04] py-2 flex flex-col items-center gap-0.5"
style={{ width: SCRUBBER_WIDTH }}
>
{scrubberYears.map((yearEntry) => (
<ScrubberYearBlock
key={yearEntry.year}
yearEntry={yearEntry}
activeGroupIndex={activeGroupIndex}
onScrollTo={scrollToGroup}
/>
))}
</div>
) : null}
{contextMenu ? (
<ContextMenu
@@ -253,3 +363,51 @@ export function Timeline() {
</div>
);
}
interface ScrubberYearBlockProps {
yearEntry: ScrubberYear;
activeGroupIndex: number;
onScrollTo: (index: number) => void;
}
function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) {
const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex);
return (
<div className="w-full flex flex-col items-center">
<Tooltip label={yearEntry.year} anchorToCursor>
<button
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
}`}
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
>
{yearEntry.year}
</button>
</Tooltip>
<div
className="grid gap-[3px] pb-1.5"
style={{ gridTemplateColumns: "repeat(3, 10px)" }}
>
{Array.from({ length: 12 }, (_, i) => {
const monthNum = i + 1;
const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum);
const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex;
if (!monthEntry) {
return <span key={monthNum} className="h-[10px] w-[10px]" />;
}
return (
<Tooltip key={monthNum} label={`${monthEntry.label} ${yearEntry.year}`} anchorToCursor>
<button
onClick={() => onScrollTo(monthEntry.groupIndex)}
className={`h-[10px] w-[10px] rounded-full transition-colors ${
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
}`}
/>
</Tooltip>
);
})}
</div>
</div>
);
}
+78 -14
View File
@@ -1,7 +1,14 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store";
import { useGalleryStore, AppTheme } from "../store";
import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
const THEME_OPTIONS: { value: AppTheme; label: string }[] = [
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
];
// SVG icons for window controls
function MinimizeIcon() {
@@ -24,7 +31,7 @@ function RestoreIcon() {
return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="#030712" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" />
</svg>
);
}
@@ -40,11 +47,40 @@ function CloseIcon() {
export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow();
// Right-clicking the settings cog opens a quick theme switcher, anchored under
// the cog so it never overflows the right edge of the window.
const settingsBtnRef = useRef<HTMLButtonElement>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMenu, setThemeMenu] = useState<{ top: number; right: number } | null>(null);
useEffect(() => {
if (!themeMenu) return;
const handleDown = (e: MouseEvent) => {
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) setThemeMenu(null);
};
const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") setThemeMenu(null); };
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [themeMenu]);
const handleSettingsContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
const rect = settingsBtnRef.current?.getBoundingClientRect();
if (!rect) return;
setThemeMenu({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
};
useEffect(() => {
// Get initial maximized state
appWindow.isMaximized().then(setIsMaximized);
@@ -79,22 +115,18 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */}
<div className="flex items-center gap-2 pl-3 pr-4">
{updatePending ? (
<div
className="group relative"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
{/* Instant tooltip (delay 0) — this affordance should read immediately. */}
<Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
<button
onClick={() => void installUpdate()}
aria-label={`Update available — click to update to Phokus v${updateVersion}`}
className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12"
>
<span className="pointer-events-none absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" />
</button>
{/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */}
<span className="pointer-events-none absolute left-0 top-full z-50 mt-1.5 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 opacity-0 shadow-lg transition-opacity duration-100 group-hover:opacity-100">
Click to update v{updateVersion}
</span>
</Tooltip>
</div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
@@ -112,9 +144,11 @@ export function TitleBar() {
className="flex items-stretch h-full"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button
ref={settingsBtnRef}
onClick={() => setSettingsOpen(true)}
title="Settings"
onContextMenu={handleSettingsContextMenu}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -122,7 +156,7 @@ export function TitleBar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</Tooltip>
{/* Minimize */}
<button
onClick={handleMinimize}
@@ -150,6 +184,36 @@ export function TitleBar() {
<CloseIcon />
</button>
</div>
{/* Quick theme switcher — opened by right-clicking the settings cog. */}
{themeMenu && (
<div
ref={themeMenuRef}
className="fixed z-50 min-w-[170px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ top: themeMenu.top, right: themeMenu.right, WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<div className="px-3 pt-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-500">Theme</div>
{THEME_OPTIONS.map((opt) => {
const active = theme === opt.value;
return (
<button
key={opt.value}
className={`flex w-full items-center justify-between gap-3 rounded-md px-3 py-1.5 text-left text-[12px] transition-colors ${
active ? "bg-white/8 text-white" : "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={() => { setTheme(opt.value); setThemeMenu(null); }}
>
{opt.label}
{active && (
<svg className="h-3.5 w-3.5 text-sky-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</div>
);
}
+67 -36
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
import { Tooltip } from "./Tooltip";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" },
@@ -55,8 +57,8 @@ function SortDropdown({
onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open
? "border-white/15 bg-white/8 text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200"
? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "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"
}`}
>
<span>{current?.label ?? "Sort"}</span>
@@ -68,14 +70,14 @@ function SortDropdown({
</svg>
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur">
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
{options.map((option) => (
<button
key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value
? "bg-white/6 text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white"
? "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={() => { onChange(option.value); setOpen(false); }}
>
@@ -106,14 +108,14 @@ function FilterPill({
}) {
const activeClass =
variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30"
: "bg-white/10 text-white";
? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50"
: "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
return (
<button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active
? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={onClick}
>
@@ -158,14 +160,20 @@ export function Toolbar() {
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
@@ -184,6 +192,12 @@ export function Toolbar() {
const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images";
// Album scope is offered while browsing an album (where it's the default) and
// while viewing similar/region results that were launched from an album.
const showAlbumScope =
activeView === "album" ||
(similarSourceAlbumId !== null &&
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
// If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => {
@@ -217,7 +231,7 @@ export function Toolbar() {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
});
setTagSuggestions(results);
setTagSuggestions(Array.isArray(results) ? results : []);
} catch {
setTagSuggestions([]);
}
@@ -253,12 +267,12 @@ export function Toolbar() {
const showCommandHints = !searchCommand && searchPanelOpen;
return (
<div className="relative z-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
<div className="relative z-40 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
{/* Primary row */}
<div className="flex items-center gap-3 px-5 h-12">
<div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */}
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
<h2 className="text-[15px] font-semibold text-white truncate max-w-48">{title}</h2>
<h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
@@ -309,24 +323,25 @@ export function Toolbar() {
}}
onFocus={() => setSearchPanelOpen(true)}
placeholder="Search files, or use /s /t"
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`}
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/>
{searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2">
<Tooltip label="Remove search command" anchorToCursor>
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.05] px-1.5 py-0.5 font-mono text-[11px] text-gray-300 transition-colors hover:bg-white/[0.08] hover:text-white"
onClick={() => { setSearchCommand(null); setTagSuggestions([]); }}
title="Remove search command"
>
{commandPrefix(searchCommand)}
</button>
</Tooltip>
</div>
) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? (
<Tooltip label="Clear search" anchorToCursor className="absolute right-2 top-1/2 -translate-y-1/2">
<button
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
title="Clear search"
className="rounded-md p-1 text-gray-500 hover:bg-white/5 hover:text-gray-200 transition-colors"
onClick={() => {
setSearchCommand(null);
setSearchQuery("");
@@ -338,13 +353,14 @@ export function Toolbar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
) : null}
</div>
</div>
{/* Tag autocomplete suggestions */}
{showTagSuggestions && tagSuggestions.length > 0 ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{tagSuggestions.map((entry) => (
<button
key={entry.tag}
@@ -368,25 +384,25 @@ export function Toolbar() {
{/* Tag mode with no suggestions yet — show a brief hint */}
{showTagSuggestions && tagSuggestions.length === 0 && searchQuery.trim().length > 0 ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/25">No matching tags</p>
</div>
) : null}
{/* Semantic mode hint */}
{searchCommand === "semantic" && searchPanelOpen ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<div className="absolute left-0 top-full z-50 mt-1.5 w-72 rounded-xl border border-white/10 bg-gray-950/98 px-3 py-2.5 shadow-2xl backdrop-blur">
<p className="text-xs text-white/40">Search by meaning and visual concepts</p>
</div>
) : null}
{/* Command hints — only shown when no command is active */}
{showCommandHints ? (
<div className="absolute left-0 top-full z-30 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
<div className="absolute left-0 top-full z-50 mt-1.5 w-64 rounded-xl border border-white/10 bg-gray-950/98 py-1 shadow-2xl backdrop-blur">
{(
[
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search AI and user tags" },
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning" },
{ command: "tag" as SearchCommand, prefix: "/t", label: "Tags", description: "Search user and AI tags" },
{ command: "semantic" as SearchCommand, prefix: "/s", label: "Semantic", description: "Search by meaning, object, mood" },
] as const
).map((option) => (
<button
@@ -419,34 +435,39 @@ export function Toolbar() {
<div className="h-4 w-px bg-white/10 shrink-0" />
{/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden">
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<Tooltip key={preset} label={`${tileSize}px tiles`} followCursor>
<button
key={preset}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors ${
i > 0 ? "border-l border-white/8" : ""
} ${
zoomPreset === preset
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)}
>
{preset === "compact" ? "S" : preset === "comfortable" ? "M" : "L"}
</button>
</Tooltip>
))}
</div>
</div>
{/* Filter row */}
{/* Filter row pills scroll horizontally on narrow widths so they never
squash or stack; the color filter stays pinned to the right. */}
<div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} />
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
{showAlbumScope ? (
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
) : null}
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
@@ -457,7 +478,17 @@ export function Toolbar() {
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
{hasAnyFailedTagging ? (
<FilterPill
label="Failed Tags"
active={failedTaggingOnly}
variant="amber"
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
<ColorFilter />
</div>
</div>
);
+164
View File
@@ -0,0 +1,164 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/**
* Lightweight custom tooltip fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
* entry position; `followCursor` keeps tracking the pointer.
*/
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
block = false,
anchorToCursor = false,
followCursor = false,
disabled = false,
className = "",
children,
}: {
label: ReactNode;
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
/** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean;
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const [mounted, setMounted] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return;
clear();
if (anchorToCursor || followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
useEffect(() => {
setMounted(true);
return clear;
}, []);
useEffect(() => {
if (disabled) hide();
}, [disabled]);
const Wrapper = block ? "div" : "span";
const cursorTooltip = (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Portaled + fixed so transformed parents and scroll containers cannot
// distort cursor-anchored tooltip coordinates.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor ? { type: "spring", stiffness: 700, damping: 45, mass: 0.35 } : { duration: 0 },
top: followCursor ? { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } : { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
);
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{disabled ? null : anchorToCursor || followCursor ? (
mounted ? createPortal(cursorTooltip, document.body) : null
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</Wrapper>
);
}
+27 -15
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
const CONTROLS_HIDE_DELAY_MS = 2500;
@@ -25,23 +27,24 @@ interface BufferedRange {
end: number;
}
function ControlButton({ onClick, title, active = false, children }: {
function ControlButton({ onClick, label, active = false, children }: {
onClick: () => void;
title: string;
label: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Tooltip label={label} anchorToCursor>
<button
className={`rounded-md p-1.5 transition-colors ${active ? "text-white" : "text-gray-300 hover:text-white"} hover:bg-white/10`}
onClick={(event) => {
event.stopPropagation();
onClick();
}}
title={title}
>
{children}
</button>
</Tooltip>
);
}
@@ -57,7 +60,9 @@ export function VideoPlayer({ src }: { src: string }) {
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
const [volume, setVolume] = useState(persistedVolume);
const [muted, setMuted] = useState(persistedMuted);
const [muted, setMuted] = useState(
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
);
const [playbackRate, setPlaybackRate] = useState(1);
const [loop, setLoop] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
@@ -89,11 +94,16 @@ export function VideoPlayer({ src }: { src: string }) {
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Read playback prefs fresh for each opened video — not reactive, so a
// settings change applies to the next video rather than the current one.
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
const startMuted = lightboxAutoMute || persistedMuted;
video.volume = persistedVolume;
video.muted = persistedMuted;
// Autoplay; if the webview blocks it the catch leaves us paused with
// controls visible, which is a fine fallback.
video.play().catch(() => {});
video.muted = startMuted;
setMuted(startMuted);
// Autoplay when enabled; if the webview blocks it the catch leaves us paused
// with controls visible, which is a fine fallback.
if (lightboxAutoplay) video.play().catch(() => {});
}, [src]);
const readBuffered = useCallback(() => {
@@ -282,7 +292,7 @@ export function VideoPlayer({ src }: { src: string }) {
return (
<div
ref={containerRef}
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
onClick={(event) => event.stopPropagation()}
>
@@ -344,7 +354,7 @@ export function VideoPlayer({ src }: { src: string }) {
{/* Control row */}
<div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} title={playing ? "Pause (Space)" : "Play (Space)"}>
<ControlButton onClick={togglePlay} label={playing ? "Pause (Space)" : "Play (Space)"}>
{playing ? (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 5h4v14H7zM13 5h4v14h-4z" />
@@ -363,7 +373,7 @@ export function VideoPlayer({ src }: { src: string }) {
<div className="flex-1" />
{/* Volume */}
<ControlButton onClick={toggleMute} title={muted ? "Unmute (M)" : "Mute (M)"}>
<ControlButton onClick={toggleMute} label={muted ? "Unmute (M)" : "Mute (M)"}>
{effectiveVolume === 0 ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
@@ -376,6 +386,7 @@ export function VideoPlayer({ src }: { src: string }) {
</svg>
)}
</ControlButton>
<Tooltip label="Volume (↑/↓)" anchorToCursor>
<input
type="range"
min={0}
@@ -385,21 +396,22 @@ export function VideoPlayer({ src }: { src: string }) {
className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()}
title="Volume (↑/↓)"
/>
</Tooltip>
{/* Playback speed */}
<div className="relative">
<Tooltip label="Playback speed" anchorToCursor>
<button
className={`min-w-12 rounded-md px-2 py-1.5 text-xs tabular-nums transition-colors hover:bg-white/10 ${playbackRate !== 1 ? "text-white" : "text-gray-300 hover:text-white"}`}
onClick={(event) => {
event.stopPropagation();
setSpeedMenuOpen((value) => !value);
}}
title="Playback speed"
>
{playbackRate}×
</button>
</Tooltip>
{speedMenuOpen ? (
<div className="absolute bottom-full right-0 z-20 mb-2 min-w-20 rounded-lg border border-white/10 bg-gray-950/95 p-1 shadow-2xl backdrop-blur">
{SPEED_OPTIONS.map((rate) => (
@@ -421,14 +433,14 @@ export function VideoPlayer({ src }: { src: string }) {
</div>
{/* Loop */}
<ControlButton onClick={toggleLoop} title={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<ControlButton onClick={toggleLoop} label={loop ? "Loop on (L)" : "Loop off (L)"} active={loop}>
<svg className={`h-4.5 w-4.5 ${loop ? "text-emerald-300" : ""}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
</svg>
</ControlButton>
{/* Fullscreen */}
<ControlButton onClick={toggleFullscreen} title={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
<ControlButton onClick={toggleFullscreen} label={fullscreen ? "Exit fullscreen (F)" : "Fullscreen (F)"}>
{fullscreen ? (
<svg className="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9H4m5 0V4m6 5h5m-5 0V4M9 15H4m5 0v5m6-5h5m-5 0v5" />
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
// Per-section accent. These all use the standard colour scale, which the
// subtle-light theme re-maps to readable dark shades via CSS variables — so no
// `light-theme:` overrides are needed (or wanted) here. See the theme note in
// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
// so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
};
const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" };
// Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
let lastIndex = 0;
let key = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
nodes.push(
<strong key={key++} className="font-semibold text-white">
{match[1]}
</strong>,
);
} else if (match[2] !== undefined) {
nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]}
</code>,
);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
const [open, setOpen] = useState(true);
return (
<section>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 text-left"
aria-expanded={open}
>
<svg
className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span>
<span className="text-[11px] font-medium text-gray-600">{section.items.length}</span>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<ul className="mt-2.5 space-y-2.5 pl-5">
{section.items.map((item, index) => (
<li key={index} className="flex gap-3">
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${style.dot}`} />
<p className="text-[13px] leading-relaxed text-gray-400">
{item.lead ? (
<>
<span className="font-semibold text-gray-100">{item.lead}</span>
{item.body ? <span> {renderInline(item.body)}</span> : null}
</>
) : (
renderInline(item.body)
)}
</p>
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew);
const appVersion = useGalleryStore((s) => s.appVersion);
const entry = getChangelogForVersion(appVersion);
useEffect(() => {
if (!whatsNewOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeWhatsNew();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [whatsNewOpen, closeWhatsNew]);
return (
<AnimatePresence>
{whatsNewOpen ? (
<motion.div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={closeWhatsNew}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
className="relative flex max-h-[min(82vh,760px)] w-[min(88vw,560px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 6 }}
transition={{ duration: 0.16 }}
>
<div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-300">What's new</p>
<h3 className="mt-1 text-lg font-semibold text-white">
Phokus v{entry?.version ?? appVersion ?? "—"}
</h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null}
</div>
<Tooltip label="Close" anchorToCursor>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={closeWhatsNew}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</Tooltip>
</div>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
{entry && entry.sections.length > 0 ? (
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on GitHub.
</p>
)}
</div>
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<button
type="button"
onClick={() => void invoke("open_changelog_url")}
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Full changelog
</button>
</Tooltip>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={closeWhatsNew}
>
Got it
</button>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}

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