57 Commits

Author SHA1 Message Date
LyAhn 40fcd1b469 ci(release): un-ignore tauri.conf.json + fix updater-json input
CI / check (push) Has been cancelled
Release / release (push) Has been cancelled
- tauri-action globs for tauri.conf.json honoring .gitignore; the global
  *.json rule hid it (force-added but still ignore-matched), so detection
  failed with 'Failed to resolve Tauri path' and it tried to re-init.
  Negate it like the cuda overlay already is.
- rename uploadUpdaterJson -> includeUpdaterJson (valid v0.6.2 input).
2026-06-14 19:58:54 +01:00
LyAhn 6b504aaae1 ci(release): pin tauri-action to v0.6.2 (the v1 ref does not exist)
Release / release (push) Has been cancelled
CI / check (push) Has been cancelled
2026-06-14 19:31:12 +01:00
LyAhn 0bd99e2c7a release: Phokus 0.1.0 release preparation
CI / check (push) Has been cancelled
Release / release (push) Has been cancelled
Merge chore/release-prep into main for the first public release.

- Release pipeline: CI (fmt, clippy -D warnings, tsc) + tag-triggered
  GitHub Actions building the NSIS installer as a draft release.
- Self-updater via GitHub Releases — launch check, Settings UI, update
  toast, and a title-bar focal-point indicator that lights up when a new
  version is ready.
- Production hardening: real CSP, scoped asset protocol, rotating file
  logging, single-instance, window-state.
- Guided first-run onboarding with background FFmpeg provisioning and
  resilient (curl-based, resumable) model/DLL downloads.
- Branding: Phokus aperture mark across the icon set + title bar, with a
  reusable PhokusMark component.
- CUDA installer variant with bundled runtime DLLs (built locally).
- MIT license, bundle metadata, NSIS-only target, and docs (README
  install/privacy, CHANGELOG, 0.1.0 release notes).
2026-06-14 18:27:48 +01:00
LyAhn 0144526a3d feat(onboarding): add a closing 'Staying current' step on updates + the app mark
- new StepUpdates: explains the title-bar update indicator with a mini
  app-window mockup, and introduces the aperture mark as the app's identity
- move the tour-closing copy off the AI features step onto this final step
- changelog: note the title-bar update indicator + one-click install, and the
  new onboarding step
2026-06-14 18:26:35 +01:00
LyAhn 2785b7d5e6 feat(updates): titlebar update indicator + dev injectors for the update flow
- light up the Phokus iris's focal point (pulsing amber) in the titlebar when
  an update is pending; click goes straight to install, with a fast custom
  tooltip (native title delay was too slow)
- PhokusMark gains an optional dotClassName to render the central focal point
- DemoPanel: inject every updater state (available/downloading/installing/
  error/up-to-date), which is otherwise unreachable without a remote latest.json
2026-06-14 17:11:00 +01:00
LyAhn 797247e900 feat(branding): replace default Tauri icon with the Phokus aperture mark
- regenerate src-tauri/icons from the new branding/phokus-aperture.svg master
- add reusable PhokusMark component (inline SVG, inherits currentColor)
- use it for the titlebar app mark, retiring the placeholder
2026-06-14 15:15:28 +01:00
LyAhn e4373195fe chore(dev): add a dev-only demo panel for screenshotting transient UI
The background-worker pipeline drains in under a second on a fast machine,
making it impossible to screenshot live. This adds a dev-only panel
(Ctrl+Shift+D) that injects a frozen mid-pipeline state into mediaJobProgress
so the worker bar can be captured at leisure, then cleared. Gated by
import.meta.env.DEV and verified absent from the production bundle, so it
never ships. A first rough cut of the broader screenshot/demo mode.
2026-06-14 09:39:29 +01:00
LyAhn 72a1a886a7 fix(sidebar): refresh UI immediately when removing a library
Removing a folder only reloaded the gallery when that exact folder was the
active selection, so on All Media (or another view) its images stayed on
screen until a manual refresh. Now the folder is dropped from the sidebar
optimistically (instant feedback while the backend deletes its images), the
gallery is always reloaded, and a failed delete resyncs the folder list.
2026-06-13 23:10:09 +01:00
LyAhn c15eed6655 fix(onboarding): no image flash when switching search demos
Switching search modes briefly showed the next demo's result images because
the results container stayed mounted and faded its opacity down from the
previous demo's visible state. Key it by demoIndex so it remounts fresh at
opacity-0 and only fades in once the new query finishes typing.
2026-06-13 21:48:41 +01:00
LyAhn 602c271531 feat(tagger): resilient model and runtime-DLL downloads via curl
ureq's read timeout doesn't fire on a stalled large transfer on some Windows
TLS stacks (schannel), so a stall hangs the download forever with no recovery.
Download the ONNX runtime DLLs and the tagger model through the system
curl.exe (Win10 1803+/Win11), which has a real inactivity timeout
(--speed-time) plus resume (-C -). The size probe uses curl too, so nothing in
the download path depends on ureq.

A stalled download discards its partial when it gives up (no more deleting the
model folder by hand to restart) and fails to a retryable error in a couple of
minutes rather than locking the UI on 'preparing'. Adds progress, extraction,
and runtime-init logging. Verified downloading both models on real Windows 11
hardware.
2026-06-13 21:48:41 +01:00
LyAhn 54fa8ab117 build(cuda): ship a CUDA installer variant with bundled runtime DLLs
The default build (default = candle-cuda) load-time-imports the CUDA runtime,
so it crashes on any machine without the toolkit. Add a CUDA release variant
that bundles the redistributable runtime DLLs (cudart/cublas/cublasLt/curand,
verified via dumpbin) so it runs on any NVIDIA machine with just a driver.

- tauri.cuda.conf.json: a 'tauri build --config' overlay (CPU release
  untouched) that points the updater at latest-cuda.json so a CUDA install
  never self-updates into the CPU build
- windows/cuda-hooks.nsh: NSIS hook embeds the DLLs via File and extracts them
  next to phokus.exe, where the Windows loader searches
- build:app:cuda script; cuda-redist/ gitignored (DLLs copied from the toolkit
  locally), with a negation so the overlay stays tracked
2026-06-13 21:48:40 +01:00
LyAhn 23095a6d05 docs: add installation, privacy, changelog, and 0.1.0 release notes
Phase 6 release collateral:
- README: Installation (unsigned-build/SmartScreen note, requirements,
  first-run downloads) and Privacy & local-first sections
- CHANGELOG.md seeded with the 0.1.0 feature set
- docs/release-notes-0.1.0.md draft for the GitHub Release body

Also corrects the CLIP model download size from ~330 MB to ~580 MB (the
actual model.safetensors is 577 MB) across the docs and the onboarding UI.
2026-06-13 10:34:15 +01:00
LyAhn 075c7e4cfb feat(sidebar): pause/resume all background work from folder context menu
The onboarding tour tells users they can right-click a folder to pause its
background work, but only the per-worker buttons in the BackgroundTasks bar
existed. Add a 'Pause/Resume background work' context-menu item that toggles
all four workers (thumbnail/metadata/embedding/tagging) for the folder.

To keep the bar and the menu in sync, worker-pause state moves from
BackgroundTasks' local state into the store (the canonical owner of app
state), so pausing from either surface reflects in both. Reuses the existing
set_worker_paused / get_worker_states commands; no backend change.
2026-06-13 09:42:51 +01:00
LyAhn 2f66b0bdb8 feat(onboarding): fuller, varied semantic search demo results
Replace the two near-identical pale ocean sunsets with three distinct
'golden sunset over water' stills — a vivid coastal sunset, a soft golden
ocean, and a golden alpine lake — so the /s demo shows a convincing,
varied result set. Drops the redundant sunset2 asset.
2026-06-13 09:29:40 +01:00
LyAhn ed9c061ac1 fix(onboarding): hide search results until typed, trim gallery grid to 2 rows
- search demo results were greyed-but-visible while the query was still
  typing, as if the app pre-knew the search; they're now invisible (space
  reserved, no layout jump) and fade in only once typing completes
- gallery preview dropped from 3 rows to 2 so the 'Click any tile' explainer
  text is visible without scrolling
2026-06-13 09:23:30 +01:00
LyAhn 09810cb868 refactor(onboarding): play animations once with replay, fix search demo results
Two refinements from testing:
- the gallery-reveal, pipeline-drain, and search-typing animations looped
  forever; they now play once and stop, with a Replay button to re-run
- the search demo's result tiles didn't match the queries. Now: a filename
  search returns one exact file, '/s golden sunset over water' shows only
  ocean-sunset stills (added a second), and '/t landscape' shows the three
  actual landscapes (valleys + alpine lake), not a foggy forest
2026-06-13 09:19:38 +01:00
LyAhn f8e981c6f6 feat(tagger): real byte progress for model download instead of a spinner
The 1.3 GB model.onnx downloaded via hf-hub's repo.get() and the ONNX
runtime DLLs via a read_to_end — neither reported bytes, so the user saw
only an indeterminate spinner on a multi-minute download.

- tagger model files now download via repo.download_with_progress with a
  HubProgress adapter over hf-hub's Progress trait (throttled 200ms events
  carrying downloaded/total bytes)
- captioner's nuget DLL download streams in 64 KB chunks, parsing
  content-length, and reports per-chunk byte progress
- TaggerModelProgress carries downloaded_bytes/total_bytes; onboarding and
  Settings show a determinate bar plus 'X MB / Y MB' for the current file
2026-06-13 08:56:49 +01:00
LyAhn 9c135179a3 feat(onboarding): real demo stills in place of gradient placeholders
Swap the fake gallery tiles' gradient stand-ins for 13 rights-clean
generated WebP stills (512px, ~25 KB each). Search demo now returns
thematically matched results — beach/dunes for the filename query, sunset
and water for /s, landscapes for /t — so the show-don't-tell steps look
like a real library. tileGradient retained for the abstract Explore and
Timeline mini-previews.
2026-06-13 08:36:50 +01:00
LyAhn b72f140737 fix(tagger): provision ONNX runtime DLLs during tagger model prep
On a clean install the WD tagger download failed instantly: only the
(UI-removed) caption model prep ever downloaded the shared ONNX Runtime
DLLs, while ensure_onnx_runtime — despite its comment — only initializes
and errors when the DLL is absent. Worse, the OnceLock cached that error
for the whole session, and the onboarding step swallowed the message.

- new captioner::provision_onnx_runtime downloads missing DLLs; tagger
  prep calls it before init
- ensure_onnx_runtime no longer caches failure (Mutex<bool>, success-only
  latch) so a later download can recover in-session
- onboarding AI step now displays taggerModelError
2026-06-13 00:00:35 +01:00
LyAhn 3b7190d353 fix(onboarding): honest framing of AI features step
Semantic search is part of the standard pipeline (CLIP auto-downloads on
first embedding batch), not opt-in — only AI tagging is. Reword the step
so 'optional' applies to what is actually optional. A true embedding
opt-out toggle is tracked as a follow-up.
2026-06-12 23:56:04 +01:00
LyAhn 7403f0cfeb feat(onboarding): guided first-run tour with background FFmpeg provisioning
Phase 5 of the 0.1.0 release prep:
- FFmpeg no longer blocks startup (or crashes the app offline): provisioning
  runs in a background thread emitting throttled ffmpeg-progress events,
  with installed/downloading/failed state queryable via get_ffmpeg_status
  and a retry command
- video jobs are invisible to claiming and tier-priority checks until FFmpeg
  is ready, so they wait as pending without failing — and without starving
  image embeddings/tagging (include_videos gating in db.rs)
- 7-step show-don't-tell onboarding wizard: welcome + live FFmpeg progress,
  real first-folder picker, faked animating pipeline bar, placeholder
  gallery tiles, cycling search-syntax demo, views overview, and an AI
  features step with a real opt-in tagger download
- skippable at every point (Escape included), persisted via
  settings/onboarding_completed.txt, re-runnable from Settings > General,
  which also gains an FFmpeg status/retry row
2026-06-12 23:09:05 +01:00
LyAhn 1640e30330 refactor(backend): zero clippy warnings, gate CI with -D warnings
Auto-fix pass (uninlined format args, needless borrows, to_vec,
size_of_val, map_err->inspect_err) plus hand fixes: fully annotated the
sqlite-vec transmute (c_char for cross-platform correctness), type alias
for the duplicate-scan tuple, slice signatures over &mut Vec/&PathBuf,
and #[allow(dead_code)] documenting the intentionally dormant caption
worker. The CI clippy step now fails on any new warning.
2026-06-12 20:59:40 +01:00
LyAhn 178754f6c3 feat: add Pan and Drag to lightbox
Users can now click and drag to pan the image when zoomed in. Previously it would just zoom into the centre of the image without any way of panning
2026-06-12 20:45:50 +01:00
LyAhn 3fb4a9685f feat(hardening): production logging, single-instance, CSP, and scoped asset protocol
Phase 4 of the 0.1.0 release prep:
- tauri-plugin-log: rotating file log (5 MB, Info) in the app log dir plus
  stdout; all 54 backend println!/eprintln! sites migrated to log macros so
  release builds finally produce diagnostics
- tauri-plugin-single-instance (registered first): second launches focus the
  existing window instead of racing the same SQLite DB with a second worker
  fleet; tauri-plugin-window-state persists window geometry
- real CSP replacing null (scripts self-only, media/images via the asset
  protocol, IPC endpoints whitelisted, object-src none) with a devCsp
  variant for Vite HMR
- asset protocol scope narrowed from '**' to thumbnails statically plus
  per-folder runtime grants (setup, add_folder, update_folder_path)

Panic audit (plan item) concluded with no changes: worker loops already
catch and log all Result errors; remaining unwraps are startup fail-fast,
poisoned-lock convention, infallible-by-construction, or test code.
2026-06-12 20:12:03 +01:00
LyAhn 890c23bdce feat(updater): self-update via GitHub Releases with launch check and settings UI
Phase 3 of the 0.1.0 release prep:
- tauri-plugin-updater + tauri-plugin-process wired into the builder, with
  updater:default / process:allow-restart capabilities
- bundle.createUpdaterArtifacts: true; endpoint points at the GitHub
  releases latest.json, pubkey baked into tauri.conf.json
- store: update status state machine (check / download progress / install)
  with a quiet launch check (prod only) that stays silent on network errors
- UI: dismissible update toast with download progress, plus an Updates group
  in Settings > General showing current version and manual check/install
2026-06-12 19:00:12 +01:00
LyAhn 34ced67fe1 ci: add CI checks and tag-triggered release workflow
Phase 2 of the 0.1.0 release prep, running on the GitHub mirror:
- ci.yml: frontend type-check/build, rustfmt gate, clippy (CPU-only) on
  windows-latest with pnpm + rust caching
- release.yml: v* tags build the NSIS installer via tauri-action and attach
  it to a draft GitHub Release with updater JSON; updater signing secrets
  referenced ahead of Phase 3
- both build --no-default-features: runners have no CUDA toolkit, and the
  shipped artifact must be the CPU/DirectML build
2026-06-12 18:41:32 +01:00
LyAhn 1629339569 build(deps): gate CUDA behind default feature, add CPU build scripts
Applies the stashed 'deps and cpu mode' work: cuda was hardcoded on the
candle dependency lines, making the candle-cuda feature flag dead and any
non-CUDA build impossible. Now default = [candle-cuda] for the main dev
machine, with pnpm dev:app:cpu / build:app:cpu (--no-default-features) for
CPU-only machines and CI. Also trims unused deps (log, uuid, tokio-full,
chrono-serde). Verified: cargo check --locked --no-default-features passes.
2026-06-12 18:41:32 +01:00
LyAhn 1d782a6d57 style(backend): apply rustfmt across the backend
Mechanical cargo fmt pass so the CI rustfmt gate starts green; no semantic
changes (verified token-level + cargo check).
2026-06-12 18:36:31 +01:00
LyAhn 69e53ed62a chore(release): add MIT license, bundle metadata, and single NSIS target
Phase 1 of the 0.1.0 release prep:
- LICENSE (MIT) plus license fields in Cargo.toml, package.json, and bundle config
- tauri.conf.json: publisher/copyright/descriptions/homepage so the NSIS
  installer no longer derives 'jezz' from the identifier as manufacturer
- version: null in tauri.conf.json — Cargo.toml is now the version source
- bundle.targets narrowed from 'all' to nsis (updater-friendly, faster builds)
- track pnpm-workspace.yaml (esbuild build-script approval, needed by CI)
2026-06-12 18:20:27 +01:00
LyAhn d84c74e241 Merge feat/settings-ui-refinement: settings redesign, in-view scoping, custom video player
UI/UX pass delivering:

- feat(settings): flat desktop redesign — VS Code-style form rows
  replace the card-based layout, dialog sizes proportionally with the
  app window (85% capped at 1400x900); fixes thumbnail-cleanup stats
  blanking mid-clean and the Compact now button staying clickable
  after compaction
- feat(views): in-view folder scope switching — Timeline, Explore, and
  Duplicates get a header scope dropdown so changing folders no longer
  bounces through the sidebar
- feat(lightbox): custom immersive video player — edge-to-edge
  playback with auto-hiding controls, drag scrubbing, volume, speed,
  loop, fullscreen, and full keyboard support, replacing the native
  browser player
- docs(readme): feature list and architecture brought up to date
2026-06-12 13:01:49 +01:00
LyAhn 4cd3bbd4fd docs(readme): bring feature list and architecture up to date
Adds everything shipped since the last README pass: live file tracking
with rename-aware watching, Timeline view, batched notifications with
mute/pause, the custom lightbox video player, in-view folder scoping
for Explore/Timeline/Duplicates, three-phase duplicate scanning,
maintenance tools (database compaction, thumbnail cleanup), per-folder
worker pausing, and the strict-priority background worker pipeline.
Features are now grouped by area, and the stack/how-it-works sections
mention mozjpeg scaled decoding, EXIF capture dates, and the watcher.
2026-06-12 12:49:30 +01:00
LyAhn 86ce7bc8e2 feat(lightbox): custom immersive video player
Replace the native <video controls> element with a custom player.
Videos now fill the lightbox media area edge-to-edge on black instead
of sitting in a padded, rounded box, with an auto-hiding control bar
over a bottom gradient (2.5s idle, always visible while paused):
play/pause, drag scrubber with buffered ranges, time readout, volume
slider with session-persisted volume/mute, playback speed (0.25-2x),
loop, and fullscreen.

Click toggles playback, double-click toggles fullscreen. Keyboard:
Space play/pause, M mute, F fullscreen, L loop, Shift+Left/Right seek
5s, Up/Down volume (unmutes) — plain arrows still navigate between
media, and all player keys are ignored while typing in the info-panel
inputs. Videos autoplay on open, falling back to paused-with-controls
if the webview blocks it.
2026-06-12 12:39:04 +01:00
LyAhn b02bf1da2b feat(views): in-view folder scope switching
Timeline, Explore, and Duplicates get a folder-scope dropdown in their
headers (Timeline via the shared Toolbar), so changing scope no longer
means leaving the feature and bouncing through the sidebar. The new
setViewFolderScope store action updates selectedFolderId while
preserving activeView: Timeline/Gallery reload images, Explore reloads
via its existing selectedFolderId effect, and Duplicates loads the
cached results for the new scope (fresh scans remain an explicit
Rescan). Sidebar behavior is unchanged — clicking a library still
opens its gallery.
2026-06-12 12:39:04 +01:00
LyAhn cd7dd89f00 feat(settings): flat desktop redesign with proportional sizing
Complete presentation rework of the Settings modal away from the
card-based tablet look:

- Dialog now sizes proportionally (85vw x 85vh, capped at 1400x900)
  and resizes with the app window instead of a fixed centred square.
- Content rebuilt as flat form rows in the VS Code preferences style:
  uppercase group headings with hairline-divided label/control rows —
  no nested cards, panels, or boxed strips. Sidebar retained.
- Duplicated content header removed; close button floats top-right.
- Folder selection restyled from bordered chips to flat divider rows;
  model path and runtime check render as plain monospace text.

Also fixes two maintenance-section bugs: thumbnail-cache stats no
longer blank to em-dashes while a cleanup is running, and the Compact
now button disables immediately after compaction instead of allowing
pointless re-runs until the section remounted.
2026-06-12 12:07:52 +01:00
LyAhn b1290268a7 Merge feat/smart-file-tracking: file tracking, notifications, and worker performance
Delivers rename-aware file tracking plus a major background-worker
performance series:

- feat: smart thumbnail cleanup and rename-aware file tracking —
  watcher handles atomic renames in place (preserving thumbnails and
  embeddings); thumbnails deleted alongside DB rows in all removal paths
- feat: notification batching, per-folder mute, and global pause
- feat(duplicates): phased scan progress with skipped-file reporting
- fix(settings): thumbnail cleanup UX with live state and time warning
- perf(thumbnails): scaled JPEG decode (mozjpeg), RGB8 pipeline, direct
  video frame grabs, continuous worker batching
- perf(workers): scaled parallel embedding preprocessing, idle-only
  backoff, EXIF orientation applied before embedding
- fix(indexer): embedding/tagging no longer claim folders mid-scan
- perf(metadata): idle-only backoff and per-item commits
- perf(workers): strict priority pipeline (thumbnails -> metadata ->
  embeddings -> tagging)
2026-06-12 11:21:25 +01:00
LyAhn d30fe47876 perf(workers): strict priority pipeline across background workers
Workers now form a priority pipeline (thumbnails -> metadata ->
embeddings -> tagging): a worker only claims jobs when no
higher-priority tier has claimable work, so stages drain one at a time
at full speed instead of all workers contending for CPU (shared rayon
pool), disk, and the DB writer simultaneously.

Each tier gate is a single read-only EXISTS query per idle tick using
the same exclusion set the corresponding claim would (that worker''s
paused folders plus actively-indexing folders), so paused or mid-scan
work never blocks lower tiers, and failed jobs stop gating once they
leave pending. Deferred workers wake within one backoff interval
(250-750ms) of the higher tier draining.

A watcher-driven trickle of new files briefly pauses lower tiers by
design. A user-initiated tag run during a large embedding backlog
waits for it; pausing embeddings per folder overrides if tags are
wanted first.
2026-06-12 10:45:12 +01:00
LyAhn cbfcbea96a perf(metadata): idle-only backoff and per-item commits
The metadata worker now sleeps its 250ms backoff only when the queue
is empty or a batch errored, claiming the next batch immediately while
work is pending. Each ffprobe result is committed and emitted
individually (in its own transaction) instead of after the whole
batch, so video metadata progress moves steadily rather than stalling
for up to 16 sequential probes.
2026-06-12 10:36:05 +01:00
LyAhn 948a489a8a fix(indexer): keep embedding and tagging off folders mid-scan
Embedding and tagging claims now exclude actively-indexing folders,
matching the thumbnail and metadata workers. Previously the embedding
worker started processing a folder while it was still being scanned,
competing with the scanner for CPU (shared rayon pool), disk, and the
DB writer — slowing scans and skewing the adaptive storage profile
toward Conservative. Video embedding jobs claimed mid-scan also
fail-fasted pointlessly since their thumbnails are deferred until
indexing completes; the existing backfill at scan end covers requeue.
2026-06-12 10:36:05 +01:00
LyAhn 334ac54e00 perf(workers): scaled embedding preprocessing and idle-only backoff
- Embedding and tagging workers now sleep their backoff interval
  (500ms/750ms) only when the queue is empty, the model is not ready,
  or a batch errored — previously every batch paid the sleep even with
  work pending.
- CLIP preprocessing no longer decodes originals at full resolution:
  decode_for_thumbnail is generalized into decode_image_scaled with a
  cover mode (shortest side >= target) and the embedder decodes JPEGs
  at the smallest DCT scale covering its 224px fill-crop, in parallel
  via rayon. The forward pass, not image decode, is now the dominant
  embedding cost.
- EXIF orientation is now applied before embedding, so rotated photos
  embed the way they are displayed (previously embedded sideways).
  Existing stored embeddings are unaffected.
2026-06-12 09:00:25 +01:00
LyAhn a4486547e8 perf(thumbnails): scaled JPEG decode and continuous worker batching
Major speedups for large-folder thumbnail generation:

- JPEGs decode through mozjpeg at the smallest DCT scale covering
  320px instead of full resolution, with catch_unwind and a fallback
  to the image crate for anything mozjpeg rejects. EXIF orientation
  preserved in both paths.
- RGB8 pipeline end to end (no RGBA round-trip) and CatmullRom resize
  in place of Lanczos3.
- Video posters grab the seeked frame directly instead of running the
  ffmpeg thumbnail filter (which decoded ~100 frames), with decode
  threads capped at 2 per process.
- Workers only sleep when the queue is empty rather than 250ms after
  every batch. Image batches commit before videos start, and videos
  run sequentially off the rayon pool (blocking ffmpeg waits were
  starving image decode) with per-item commits so progress keeps
  moving through slow stretches.
2026-06-12 08:13:12 +01:00
LyAhn 665c315f56 feat(duplicates): phased scan progress with skipped-file reporting
Replace the [scanned, total] tuple progress event with a structured
{phase, processed, total, skipped} payload covering all three scan
phases (checking, hashing, confirming). find_duplicates now returns a
DuplicateScanResult with scanned/candidate/skipped counts, and the UI
surfaces phase labels plus a warning when unreadable files were
skipped. Also adds a clean:app script for cargo clean.
2026-06-12 08:12:49 +01:00
LyAhn a34d38d9d3 feat: notification batching, per-folder mute, and global pause
Debounce embedding/tagging completion notifications per folder (6s window)
so rapid file additions from downloaders fire one notification instead of
one per file. Add per-folder mute via the sidebar context menu and a global
pause toggle in Settings > General, both persisted across restarts.
2026-06-11 06:28:16 +01:00
LyAhn b89e7406e9 fix(settings): improve thumbnail cleanup UX with live state and time warning
Clear stale stats during cleanup, show zero when done, and warn when the
file count is large enough to take a few minutes.
2026-06-09 06:45:24 +01:00
LyAhn ceb51f8fad feat: smart thumbnail cleanup and rename-aware file tracking
Delete thumbnails alongside DB rows in all three removal paths (watcher
detect, delete-from-disk command, remove-folder command) so orphans no
longer accumulate passively.

Intercept RenameMode::Both watcher events and handle them as in-place
path updates: renames the thumbnail file to the new hash and updates the
DB row without touching the embedding, avoiding a full rebuild on move.
Falls back to thumbnail regeneration if the file rename fails.
2026-06-09 01:16:12 +01:00
LyAhn 8eaa0bd8e8 feat: settings modal overhaul with General section and maintenance tools
- Remove Workers section (status-only, not settings)
- Remove Captioning coming-soon placeholder
- Add inline validation errors for threshold and batch size inputs
- Persist tagging queue scope and folder selection across app restarts
- Fix acceleration save error routing to correct UI location
- Add General section: open app data folder button
- Add Compact database card: live size metrics and VACUUM on demand
- Add Thumbnail cache card: orphaned thumbnail count, size, and cleanup
2026-06-09 01:09:40 +01:00
LyAhn 3707a35cc4 feat(settings): add orphaned thumbnail cleanup to General section
Scans the thumbnails directory and cross-references against thumbnail_path
values in the images table, deleting any files not linked to an indexed image.
Surfaces count and reclaimable MB in the General section alongside the
existing Compact database card.
2026-06-09 01:09:29 +01:00
LyAhn d1eb75a4f5 feat(settings): add compact database card to General section
Adds get_database_info and vacuum_database Tauri commands. The General
section now shows current DB size and reclaimable space on load, with a
Compact now button that runs PRAGMA wal_checkpoint(FULL) + VACUUM and
reports how many MB were freed. Button disables automatically when the
database is already compact (< 0.5 MB reclaimable).
2026-06-09 00:40:05 +01:00
LyAhn fbdd43d9d9 fix(settings): correct invoke payload keys and error handler routing
- folder_ids: use snake_case in both set_tagging_queue_folder_ids invoke
  calls (toggleTaggingQueueFolder and setTaggingQueueFolderIds) to match
  the Rust SetTaggingQueueFolderIdsParams serde field name
- threshold save error: route setTaggerThreshold() catch to
  setTaggerThresholdError instead of setTaggerQueueStatus so the error
  surfaces next to the threshold input, not the queue targets card
2026-06-09 00:22:30 +01:00
LyAhn 33fb3c6c77 feat(settings): overhaul Settings modal with improvements and General section
- Remove Workers section (read-only status, belongs in background tasks panel)
- Remove Captioning coming-soon placeholder
- Add General section with Open data folder button (tauri-plugin-opener)
- Persist queue scope and folder selection across sessions via settings files
- Show inline validation errors on threshold/batch size inputs instead of silent revert
- Fix acceleration save errors appearing in wrong card
- Disable folder selection controls when target scope is All media
2026-06-09 00:14:49 +01:00
LyAhn a40e5c2771 feat: watchdog, EXIF capture date, and timeline view
Merges feat/watchdog-exif-timeline into main.

- Adaptive filesystem watchdog: switches between inotify/ReadDirectoryChanges
  and polling with zero CPU when idle; watcher events update sidebar counts live
- EXIF taken_at: extracted at index time and stored as a sortable column;
  new taken_asc/taken_desc sort orders; migration is safe on existing DBs
- Timeline view: virtualised month-grouped grid (react-virtual v3), reuses
  ImageTile/ContextMenu, Calendar icon in Sidebar, defaults to taken_asc sort
- Bug fixes: startup migration panic on existing DBs without taken_at column,
  "Unknown Date" grouping due to null created_at fallback, watcher not updating
  folder image counts, cols overflow in Timeline grid
2026-06-08 22:54:33 +01:00
LyAhn a2804d8c1b fix(timeline): CodeRabbit review corrections
- Fix cols overflow: use (containerWidth - GAP) formula so the grid
  never exceeds the container width
- Explicitly pin "unknown" date keys to the end of the sort rather than
  relying on alphabetical accident ("u" > digits)
- Guard buildLabel against non-YYYY-MM keys with finite/range checks
  and an isNaN fallback to "Unknown Date"
- Clear similarSourceFolderId, similarFolderId, and similarCrop when
  switching to the timeline view so all similar-state is consistently
  reset
2026-06-08 22:26:28 +01:00
LyAhn 0ab156d2d9 fix: startup crash, watcher count, timeline date fallback
db.rs:
- Move idx_images_taken_at creation to after ensure_column so it never
  runs against a schema where taken_at doesn't exist yet; this was causing
  a startup panic on any DB that predates Phase 1
- COALESCE(taken_at, created_at) → COALESCE(taken_at, modified_at) since
  created_at is never populated (modified_at is always set)
- Remove dead is_tagging_job_cancelled function (superseded by
  is_tagging_job_processing) and unused thumbnail_path from ImagePathRecord

indexer.rs:
- Watcher create path now calls update_folder_count after commit_batch and
  emits folder-counts-changed so the sidebar count stays in sync; the
  notification is only emitted when the DB write actually succeeds

store.ts / Timeline.tsx:
- compareImages taken_asc/taken_desc: fall back to modified_at not
  created_at (created_at is always null)
- Timeline groupImages: same fallback fix so images group by month
  correctly for libraries not yet re-indexed for EXIF
- subscribeToProgress: listen for folder-counts-changed and call
  loadFolders() to keep the sidebar image count live
2026-06-08 22:09:50 +01:00
LyAhn 9ace1f6778 chore: remove plan handoff document 2026-06-08 20:06:17 +01:00
LyAhn ec6be96c6a feat(timeline): add virtualised month-grouped timeline view
- New Timeline view groups all media by YYYY-MM using taken_at ?? created_at
- @tanstack/react-virtual (group-level virtualiser) keeps scroll smooth on
  large libraries; estimateSize is exact so no measureElement needed
- ResizeObserver tracks container width; virtualizer.measure() is called on
  cols change to prevent stale position cache after window resize
- setView("timeline") in store auto-sets sort:"taken_asc", resets images,
  and triggers a fresh load
- Calendar nav item added to Sidebar between Explore and Duplicates
- ContextMenu and ImageTile exported from Gallery for reuse in Timeline
2026-06-08 18:19:01 +01:00
LyAhn ae9e806e61 feat(watcher): adaptive filesystem watchdog with zero CPU when idle
Monitors all registered folders using OS-native events (ReadDirectoryChangesW
on Windows) so new, modified, and deleted files are reflected in the gallery
automatically without manual reindexing.

Key design points:
- Adaptive blocking: recv() when no events pending (zero CPU), switches to
  recv_timeout(earliest_deadline) only when debounce timers are running — wakes
  exactly when the soonest event is ready, no busy-polling
- 500 ms per-path debounce coalesces rapid OS event bursts into one action
- Change detection preserved: build_record skips upsert if file_size + mtime
  unchanged, preventing spurious thumbnail/embedding re-queues and avoiding
  clobbering of existing metadata like thumbnail_path
- Access events (reads) filtered out; only Create/Modify/Remove trigger work
- Deletion path: emits watcher-deleted event with image IDs; frontend removes
  those images from state and clears selectedImage if it was deleted

WatcherHandle stored in app state; add_folder / remove_folder / update_folder_path
commands keep the watched path set in sync with the DB.
2026-06-08 06:50:01 +01:00
LyAhn 9ee5b08c93 feat(exif): extract capture date and surface as sortable taken_at field
Adds EXIF date extraction during indexing so photos can be sorted by when
they were actually taken, not just when the file was modified on disk.

- Add `taken_at` (nullable TEXT, ISO 8601) column to the images table via
  ensure_column migration and a new idx_images_taken_at index
- Populate taken_at in build_record via extract_exif_date, which tries
  DateTimeOriginal → DateTimeDigitized → DateTime tags and rejects all-zero
  sentinel dates written by uninitialised cameras
- upsert_image uses taken_at = excluded.taken_at so a re-indexed file gets
  fresh EXIF; stale dates from replaced files are not preserved
- Add taken_asc / taken_desc sort using COALESCE(taken_at, created_at) so
  images without EXIF gracefully fall back to their creation date
- Surface as "Taken: newest / oldest" in the Toolbar sort dropdown
- Shift map_image_row column indices after the new taken_at (index 10)
2026-06-08 00:26:36 +01:00
LyAhn 0ca4d142d8 feat(discovery): AI tagging, semantic search, similarity, duplicate finder, folder management
Adds a full discovery and organisation layer to the gallery.

## AI Tagger
- WD tagger (ONNX via ort) with DirectML/CPU acceleration
- Per-image and per-folder tag queuing; configurable batch size and
  confidence threshold; model downloaded on first use via ureq/zip
- Tags stored with source ('ai' | 'user'); user tags are never
  overwritten by AI; AI tags protected from accidental promotion
- Tagger pause/resume per folder; in-flight batches discarded cleanly
  on pause or cancellation without leaving jobs stuck in processing

## Semantic & Tag Search
- CLIP text-query embedding via candle (HuggingFace hub model)
- Progressive candidate doubling with filter post-processing so
  folder/rating/media-kind filters do not silently under-return results
- Tag search with DB-backed pagination (offset + COUNT total)
- Filename search unchanged; prefix syntax: s:, t:, f: in search bar

## Similarity Search
- HNSW in-memory index (hnsw_rs) with monotonic embedding_revision
  counter for cache invalidation; build_index retries if a concurrent
  write advances the revision during construction
- Folder-scoped similar search uses brute-force cosine scan (no k limit)
- Region-based similarity: crop an arbitrary rectangle in the lightbox,
  embed it with CLIP, find the nearest images globally or within folder
- Pagination for both similar and region results; scope toggle
  (all media / current folder) re-runs the query without reopening image

## Duplicate Finder
- Three-phase detection: stat (size) → sample hash (4×16 KB windows)
  → full-file hash (xxh3, large files only) to eliminate false positives
- Filesystem-first deletion: only removes DB rows for files successfully
  deleted from disk; failed files remain visible for retry
- Persisted scan cache (SQLite) with invalidation on reindex and deletion;
  both global and per-folder scopes invalidated after any deletion

## Tag Cloud & Explore
- Visual cluster explore: k-means over CLIP embeddings, configurable k,
  representative thumbnail per cluster; cache keyed on xxh3 of embedding set
- Tag explore: ranked tag list with image counts and representative
  thumbnail per tag; invalidated when AI tagging completes or folder removed
- Tag autocomplete for search bar

## Folder Management
- Inline rename (display name only, not OS rename)
- Relocate: file-picker to update folder path; all image paths rewritten
  using SUBSTR prefix replacement to avoid corrupting paths that contain
  the folder name as a substring
- Missing-folder recovery banner: Locate or Remove, never silent deletion
- Right-click context menu on sidebar items: Reindex, Rename, Locate,
  Remove; hover buttons preserved alongside context menu

## Background Tasks
- Per-folder progress panel with embedding, tagging, caption, and
  thumbnail stage breakdown
- Retry button per task for failed embeddings and tagging jobs
- Completed-task notifications (system tray via tauri-plugin-notification)
- Scan errors shown inline; WalkDir permission errors protect existing
  records from deletion rather than cascading data loss

## Backend correctness
- sqlite-vec DML performed outside transactions throughout (virtual-table
  limitation); embedding revision incremented on delete as well as insert
- Tagging job discard checks status = 'processing' so paused jobs reset
  to 'pending' are also skipped, not just cancelled ones
- Stale async responses in Lightbox guarded with cancellation flags and
  currentImageIdRef for both tag-load and tag-add callbacks
- Duplicate cache cleared on reindex; folder-removal clears vector rows
  outside transaction before deleting the folder row
2026-06-07 22:43:16 +00:00
93 changed files with 15743 additions and 1013 deletions
+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
# 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
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
# tsc + vite build; also produces dist/ which tauri's build script expects
- name: Type-check and build frontend
run: pnpm build:vite
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Rustfmt
working-directory: src-tauri
run: cargo fmt --check
# --no-default-features: default enables candle-cuda for the main dev
# machine; CI runners have no CUDA toolkit and must build CPU-only.
- name: Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
+57
View File
@@ -0,0 +1,57 @@
name: Release
# Tag pushes mirrored from Gitea (git.jezz.wtf) trigger this on the GitHub
# mirror. Builds the NSIS installer and attaches it to a DRAFT GitHub Release —
# review and publish manually.
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
release:
permissions:
contents: write
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build and create draft release
uses: tauri-apps/tauri-action@v0.6.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Updater artifact signing (Phase 3) — set these repo secrets once
# the updater keypair exists; empty values are ignored until then.
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'Phokus v__VERSION__'
releaseBody: 'See the assets below to download and install this version.'
releaseDraft: true
prerelease: false
includeUpdaterJson: true
updaterJsonPreferNsis: true
# Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features'
+17
View File
@@ -29,3 +29,20 @@ dist-ssr
# Local staging area # Local staging area
/staging /staging
# 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".
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
+50
View File
@@ -0,0 +1,50 @@
# Changelog
All notable changes to Phokus are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
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
First public release. Windows desktop, distributed as an unsigned NSIS
installer with a built-in updater.
### Added
- **Local media library** — add folders, recursive background indexing with
live progress, and a filesystem watcher that keeps the library in sync as
files are added, edited, moved, renamed, or removed (thumbnails and
embeddings are preserved across renames).
- **Gallery** — virtualized grid (handles very large libraries), favorites,
star ratings, video durations; filter by folder, type, favorites, or
rating; sort by date added, date taken (EXIF), name, size, rating, or
duration; compact / comfortable / detail density.
- **Search** — filename, semantic (`/s`, via CLIP visual embeddings), and tag
(`/t`) search from one prefix-aware search bar.
- **Discovery** — similar-image search (by image or selected region), an
Explore view with a visual cluster map and tag cloud, and a Timeline grouped
by EXIF capture date. Explore, Timeline, and Duplicates are folder-scopable
from their headers.
- **Lightbox** — keyboard navigation, zoom, pan, inline tag editing, and
rating controls, plus a custom edge-to-edge video player (scrubbing, volume,
speed, loop, fullscreen, keyboard support).
- **AI tagging** — WD tagger (ONNX, CPU/DirectML) with adjustable confidence
threshold, batch size, and per-folder queue targeting. Optional.
- **Duplicate finder** — three-phase exact-duplicate scan
(size → sample hash → full hash) with live progress and bulk delete.
- **Background pipeline** — strict-priority workers (thumbnails → metadata →
embeddings → tags) with per-folder pausing from the sidebar context menu or
the background-tasks bar.
- **Guided first-run onboarding** — background FFmpeg provisioning with live
progress and retry, a walkthrough of the library, pipeline, search modes,
views, and updates, plus an optional AI-tagger download. Re-runnable from
Settings.
- **Updater** — checks GitHub Releases on launch and from Settings; a title-bar
indicator lights up when a new version is ready, and one click downloads,
installs the signed update, and relaunches.
- **Maintenance** — database compaction and orphaned-thumbnail cleanup from
Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling.
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
+94
View File
@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
**Phokus** — a Tauri v2 desktop image gallery app with a React/TypeScript frontend and a Rust backend. The app indexes local media folders, generates thumbnails via FFmpeg, computes visual embeddings for semantic search and similarity, and AI-tags images using the WD tagger (ONNX via `ort`). AI captioning code exists in the backend but the UI surface has been removed; the worker is commented out in `lib.rs`.
## Commands
```bash
# Development (Vite hot-reload + Rust auto-rebuild)
pnpm dev:app
# Frontend only (no Tauri window)
pnpm dev:vite
# Production build
pnpm build:app
# Type-check frontend
pnpm build:vite
```
Use **pnpm** — never npm.
There are no test suites configured.
## Architecture
### Frontend (`src/`)
- **`store.ts`** — single Zustand store (`useGalleryStore`) that owns all app state and all `invoke()` calls to the Tauri backend. Every feature (folders, images, search, similar images, tags, captions, tagger, duplicates) is implemented as store actions here. React components are thin consumers.
- **`App.tsx`** — sets up Tauri event listeners (`subscribeToProgress`) and renders the top-level layout (sidebar + active view).
- **`src/components/`** — UI components: `Gallery`, `Lightbox`, `Sidebar`, `Toolbar`, `TagCloud`, `DuplicateFinder`, `BackgroundTasks`, `SettingsModal`, `MenuBar`, `TitleBar`.
- State management: Zustand v5, no selectors library — components call `useGalleryStore(s => s.field)` directly.
- Styling: Tailwind CSS v4 (Vite plugin, no config file).
- Virtualized gallery grid: `@tanstack/react-virtual`.
- Animation: `framer-motion`.
### Search modes
The search bar supports prefix syntax parsed by `parseSearchValue` in `store.ts`:
- No prefix / `f:` — filename search (paginated, DB-backed)
- `/s <query>` or `s: <query>` — semantic (embedding) search
- `/t <tag>` or `t: <tag>` — tag search
### Backend (`src-tauri/src/`)
Workers are started in `lib.rs` and run as background threads throughout the app lifetime:
- **thumbnail worker** (multiple threads, count from `StorageProfile::Balanced`)
- **metadata worker** — FFmpeg probe for video files
- **embedding worker** — generates CLIP-style visual embeddings (candle, HuggingFace hub)
- **tagging worker** — WD tagger via ONNX Runtime (`ort`), DirectML/CPU acceleration
Key modules:
| File | Purpose |
|------|---------|
| `db.rs` | SQLite pool (r2d2 + rusqlite), schema migrations, all query functions |
| `commands.rs` | All `#[tauri::command]` handlers — one-to-one with frontend `invoke()` calls |
| `indexer.rs` | Worker thread launchers and job dispatch |
| `embedder.rs` | Visual embedding generation (candle + HF hub models) |
| `vector.rs` | sqlite-vec integration + HNSW index for ANN search |
| `hnsw_index.rs` | In-memory HNSW index wrapper (hnsw_rs) |
| `tagger.rs` | WD tagger: ONNX model download, inference, CSV tag loading |
| `captioner.rs` | AI captioning (ONNX, disabled in workers but code intact) |
| `thumbnail.rs` | Thumbnail generation (image crate + fast_image_resize, FFmpeg for video) |
| `media.rs` | FFmpeg sidecar provisioning and probing |
| `storage.rs` | `StorageProfile` for tuning worker counts |
Database: SQLite with WAL mode, stored in the Tauri app data directory as `gallery.db`. Thumbnails stored alongside as `thumbnails/`.
### Tauri events (backend → frontend)
| Event | Payload |
|-------|---------|
| `index-progress` | `IndexProgress` |
| `media-job-progress` | `MediaJobProgressEvent` |
| `indexed-images` | `IndexedImagesBatch` |
| `media-updated` | `ThumbnailBatch` |
| `caption-model-progress` | `CaptionModelProgress` |
| `tagger-model-progress` | `TaggerModelProgress` |
| `duplicate_scan_progress` | `{ phase, processed, total, skipped }` |
### Key types
`ImageRecord` (mirrored in `store.ts` and `db.rs`) is the central data type. It carries embedding status, tagging status, caption data, and media metadata. The frontend type must stay in sync with the Rust struct serialization.
## Development notes
- Hot Reload is active during `dev:app` — do not restart the server for frontend changes. Restart only when adding Rust crates or changing Vite config.
- ML inference crates (`candle-*`, `ort`, `image`, `rayon`, `tokenizers`, `xxhash-rust`, `rusqlite`) use `opt-level = 3` in dev profile to keep inference performance acceptable.
- The caption worker is intentionally disabled (`lib.rs:73`) — the backend code is intact for future re-enabling.
- **Never use `any` type** in TypeScript — look up correct types.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 JezzWTF
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+95 -85
View File
@@ -1,116 +1,126 @@
# Phokus # Phokus
## Overview A local-first desktop media library for browsing, filtering, and curating image and video folders.
Phokus is a Tauri desktop app for building a fast, local media library from folders on disk. It indexes images and videos, stores metadata in SQLite, and gives you a dense browsing workflow with filtering, favorites, ratings, and a lightbox preview. ## Features
The current app is optimized for: ### Library
- Add and remove media folders; background indexing with live progress
- **Live file tracking** — a filesystem watcher keeps the library in sync as files are added, changed, or removed; renames and moves are handled in place, preserving thumbnails and embeddings
- Browse all media or filter by folder, type (image/video), favorites, or star rating
- Sort by date added, date taken (EXIF), name, size, rating, or duration
- Grid density controls (compact / comfortable / detail)
- Desktop notifications, batched per folder, with per-folder mute and a global pause
- local folders instead of cloud import flows ### Search & discovery
- large visual libraries - **Filename search**, **semantic search** (`/s query`), and **tag search** (`/t tag`)
- quick review and curation - **Similar image search** — find visually similar media by image or a selected region
- mixed image and video browsing - **Explore view** — visual cluster map and tag cloud for browsing by theme
- **Timeline view** — media grouped chronologically by capture date (EXIF)
- **Duplicate finder** — three-phase exact-duplicate scan (size → sample hash → full hash) with live progress and bulk delete
- Explore, Timeline, and Duplicates are folder-scopable directly from their headers — no need to bounce through the sidebar
## Current features ### Viewing & curation
- Lightbox with keyboard navigation, zoom, inline tag editing, and rating controls
- **Custom video player** — immersive edge-to-edge playback with scrubbing, volume, playback speed, loop, and fullscreen; auto-hiding controls and full keyboard support
- **AI tagging** via WD tagger (ONNX, CPU/DirectML) with confidence threshold, batch size, and per-folder queue targeting
- Add and remove media folders ### Maintenance
- Background indexing with progress updates - Database compaction and orphaned-thumbnail cleanup from Settings, with live size/reclaimable stats
- Browse all media or filter by folder - Per-folder pausing of background work (thumbnails, metadata, embeddings, tagging)
- Search by filename
- Filter by images, videos, or favorites
- Sort by modified date, name, or file size
- Grid density controls
- Lightbox preview with keyboard navigation
- Favorite and star-rating metadata saved in SQLite
- Virtualized/local-first architecture built on Tauri + React
## Supported formats ## Supported formats
Images: | Images | Videos |
|--------|--------|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
| tiff, tif, webp, avif, heic, heif | webm |
- `jpg` ## Installation
- `jpeg`
- `png`
- `gif`
- `bmp`
- `tiff`
- `tif`
- `webp`
- `avif`
- `heic`
- `heif`
Videos: Phokus is a **Windows desktop app**. Download the latest installer from the
[Releases page](https://github.com/JezzWTF/phokus/releases/latest) and run it.
- `mp4` **Requirements:** Windows 10 or 11. The installer will fetch the WebView2
- `mov` runtime automatically if it isn't already present (it ships with Windows 11).
- `m4v`
- `webm` ### A note on the unsigned build
Phokus 0.1.0 installers are **not code-signed**. Code-signing certificates are
an ongoing expense that's hard to justify for an unfunded open-source project,
so signing is on the roadmap rather than in place today.
In practice this means Windows SmartScreen will show a blue
**"Windows protected your PC"** warning the first time you run the installer.
To proceed: click **More info → Run anyway**. If a release publishes a SHA-256
checksum, you can verify the download against it first.
### First run
On first launch Phokus downloads a few tools and models — all one-time, and all
processed on your machine:
- **FFmpeg** (~tens of MB) for video thumbnails and metadata — downloaded in the
background; the guided first-run tour shows progress.
- **WD tagger model** (~1.3 GB) — **optional**, only if you enable AI tagging.
- **CLIP embedding model** (~580 MB) — downloaded automatically the first time
visual embeddings run (powers semantic search and similar images).
## Privacy & local-first
Phokus is local-first by design. **Your media and everything derived from it —
thumbnails, embeddings, tags, ratings — never leave your machine.** There is no
account, no telemetry, and no cloud sync.
The only network activity is:
- **One-time tool/model downloads** on first use (FFmpeg, the CLIP and WD
models, and the ONNX runtime), fetched from their official sources
(FFmpeg builds, Hugging Face, NuGet). These pull *tools to your machine*
none of your images are uploaded.
- **Update checks** against the GitHub Releases page, so the app can offer new
versions. This can be ignored if you never update.
Everything Phokus stores lives in its app-data directory (`gallery.db`,
`thumbnails/`, `models/`, `settings/`). Removing that directory resets the app
completely; your original media folders are never modified.
## Stack ## Stack
- Tauri 2 - Tauri 2 + Rust backend
- React 19 - React 19 + TypeScript + Zustand
- TypeScript - SQLite + `sqlite-vec` (vector search) + HNSW index
- Zustand - ONNX Runtime (`ort`) for AI tagging
- Rust - Candle (Rust ML) for CLIP visual embeddings
- SQLite + `sqlite-vec` - mozjpeg for fast scaled JPEG decoding
- Vite - FFmpeg sidecar for video thumbnails and metadata
- Vite + Tailwind CSS v4
## Project structure
- `src/`: React UI, state, and components
- `src-tauri/src/commands.rs`: Tauri command surface
- `src-tauri/src/db.rs`: SQLite schema and queries
- `src-tauri/src/indexer.rs`: folder crawling and batch indexing
- `src-tauri/src/vector.rs`: vector table setup for future semantic workflows
## Development ## Development
### Prerequisites **Prerequisites:** Node.js 20+, pnpm, Rust toolchain, Tauri system prerequisites for Windows.
- Node.js 20+
- `pnpm`
- Rust toolchain
- Tauri system prerequisites for Windows
### Install
```bash ```bash
pnpm install pnpm install
```
### Run in development # Run with hot-reload (frontend + Rust)
pnpm dev:app
```bash # Frontend only
pnpm tauri dev pnpm dev:vite
```
### Build # Production build
pnpm build:app
```bash # Type-check the frontend
pnpm tauri build pnpm build:vite
``` ```
## How it works ## How it works
1. Add a folder from the sidebar or Library menu. 1. Add a folder from the sidebar — the Rust indexer walks it recursively.
2. The Rust indexer walks the directory recursively. 2. Supported files are written to SQLite with metadata (path, dimensions, media type, EXIF capture date, etc.).
3. Supported files are written into SQLite with metadata such as path, size, dimensions, media type, rating, and favorite state. 3. Background workers process the queue as a strict priority pipeline — thumbnails first, then video metadata, then visual embeddings, then AI tags — so each stage runs at full speed instead of competing for CPU, disk, and the database.
4. Progress events stream back to the UI while the gallery updates incrementally. 4. Progress events stream back to the UI while the gallery updates incrementally.
5. The gallery view loads media in pages and opens items in a lightbox for review. 5. A filesystem watcher picks up later changes (new files, edits, deletions, renames) and keeps the index current without rescans.
6. Embeddings power semantic search and the similar-images feature via an HNSW index.
## Notes
- This is currently a local desktop library, not a sync product.
- Search is filename-based right now.
- The vector table and embedding fields exist, but semantic search is not wired into the UI yet.
- Some visible UI copy may still use the old working name until the frontend text is updated.
## Positioning
The clearest product description today is:
> A local-first desktop media library for browsing, filtering, and curating image and video folders.
That description is more accurate than "gallery" alone and gives you a better base for future branding, onboarding copy, and a landing page.
+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

+59
View File
@@ -0,0 +1,59 @@
# Phokus v0.1.0
> Draft for the GitHub Release body. Paste into the release, fill in the
> checksum, 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 the **first public release**. Expect rough edges, and please file
issues.
## Install
1. Download `Phokus_0.1.0_x64-setup.exe` below.
2. Run it. **Windows SmartScreen will warn** that the publisher is
unrecognized — this build is **not code-signed** (cost; signing is on the
roadmap). Click **More info → Run anyway** to proceed.
3. Requires **Windows 10/11**. The installer fetches the WebView2 runtime
automatically if needed.
On first launch, Phokus runs a short guided tour and downloads FFmpeg in the
background (one-time, ~tens of MB). AI tagging (~1.3 GB) is optional; the CLIP
model for semantic search (~580 MB) downloads automatically the first time
embeddings run. **All of this stays on your machine.**
## Highlights
- Virtualized gallery for very large libraries; favorites, ratings, filters, sorts
- Filename, semantic (`/s`), and tag (`/t`) search
- Similar-image search, Explore clusters + tag cloud, EXIF Timeline
- Lightbox with zoom/pan and a custom video player
- Optional on-device AI tagging (WD tagger)
- Exact-duplicate finder with bulk delete
- Built-in updater
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Privacy
Your media and all derived data (thumbnails, embeddings, tags, ratings) never
leave your machine. No account, no telemetry, no cloud. The only network
activity is the one-time tool/model downloads above and update checks against
this Releases page.
## Known limitations
- **Unsigned installer** — SmartScreen warning as described above.
- **Windows only** for now.
- CPU/DirectML inference in the shipped build; very large libraries take time
to embed on first index.
## Verify your download (optional)
```
SHA-256 (Phokus_0.1.0_x64-setup.exe) = <fill in at publish time>
```
+1 -3
View File
@@ -2,11 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title> <title>Phokus</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
+14 -2
View File
@@ -2,10 +2,17 @@
"name": "phokus", "name": "phokus",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "build:app": "tauri build",
"build": "tsc && vite build", "build:vite": "tsc && vite build",
"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:vite": "vite",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
@@ -14,7 +21,11 @@
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0", "@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0", "@tauri-apps/plugin-fs": "^2.5.0",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"d3-force": "^3.0.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
@@ -23,6 +34,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/d3-force": "^3.0.10",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
+69
View File
@@ -20,9 +20,21 @@ importers:
'@tauri-apps/plugin-fs': '@tauri-apps/plugin-fs':
specifier: ^2.5.0 specifier: ^2.5.0
version: 2.5.0 version: 2.5.0
'@tauri-apps/plugin-notification':
specifier: ^2.3.3
version: 2.3.3
'@tauri-apps/plugin-opener': '@tauri-apps/plugin-opener':
specifier: ^2 specifier: ^2
version: 2.5.3 version: 2.5.3
'@tauri-apps/plugin-process':
specifier: ^2.3.1
version: 2.3.1
'@tauri-apps/plugin-updater':
specifier: ^2.10.1
version: 2.10.1
d3-force:
specifier: ^3.0.0
version: 3.0.0
framer-motion: framer-motion:
specifier: ^12.38.0 specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -42,6 +54,9 @@ importers:
'@tauri-apps/cli': '@tauri-apps/cli':
specifier: ^2 specifier: ^2
version: 2.10.1 version: 2.10.1
'@types/d3-force':
specifier: ^3.0.10
version: 3.0.10
'@types/react': '@types/react':
specifier: ^19.1.8 specifier: ^19.1.8
version: 19.2.14 version: 19.2.14
@@ -647,9 +662,18 @@ packages:
'@tauri-apps/plugin-fs@2.5.0': '@tauri-apps/plugin-fs@2.5.0':
resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==} resolution: {integrity: sha512-c83kbz61AK+rKjhS+je9+stIO27nXj7p9cqeg36TwkIUtxpCFTttlHHtqon6h6FN54cXjyAjlMPOJcW3mwE5XQ==}
'@tauri-apps/plugin-notification@2.3.3':
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==} resolution: {integrity: sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==}
'@tauri-apps/plugin-process@2.3.1':
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
'@tauri-apps/plugin-updater@2.10.1':
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -662,6 +686,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/d3-force@3.0.10':
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -698,6 +725,22 @@ packages:
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-dispatch@3.0.1:
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
engines: {node: '>=12'}
d3-force@3.0.0:
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
engines: {node: '>=12'}
d3-quadtree@3.0.1:
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@@ -1423,10 +1466,22 @@ snapshots:
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-notification@2.3.3':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-opener@2.5.3': '@tauri-apps/plugin-opener@2.5.3':
dependencies: dependencies:
'@tauri-apps/api': 2.10.1 '@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-process@2.3.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@tauri-apps/plugin-updater@2.10.1':
dependencies:
'@tauri-apps/api': 2.10.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
@@ -1448,6 +1503,8 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.0
'@types/d3-force@3.0.10': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/react-dom@19.2.3(@types/react@19.2.14)': '@types/react-dom@19.2.3(@types/react@19.2.14)':
@@ -1486,6 +1543,18 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
d3-dispatch@3.0.1: {}
d3-force@3.0.0:
dependencies:
d3-dispatch: 3.0.1
d3-quadtree: 3.0.1
d3-timer: 3.0.1
d3-quadtree@3.0.1: {}
d3-timer@3.0.1: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+12
View File
@@ -0,0 +1,12 @@
[target.x86_64-pc-windows-msvc]
rustflags = [
# Disable MSVC incremental linking — avoids .ilk file corruption
# and removes one source of link failure on restart.
"-C", "link-arg=/INCREMENTAL:NO",
# Skip PDB generation entirely in dev builds.
# The .pdb file is the most common reason the linker fails after
# an unclean shutdown: the previous phokus.exe process holds the
# file open, so link.exe cannot write a new one → LNK error.
# Rust backtraces still work without MSVC PDBs.
"-C", "link-arg=/DEBUG:NONE",
]
+1101 -19
View File
File diff suppressed because it is too large Load Diff
+78 -9
View File
@@ -1,8 +1,9 @@
[package] [package]
name = "phokus" name = "phokus"
version = "0.1.0" version = "0.1.0"
description = "A performant image gallery application" description = "Local-first desktop media library"
authors = ["JezzWTF"] authors = ["JezzWTF"]
license = "MIT"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -13,7 +14,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] } tauri-build = { version = "2", features = [] }
[features] [features]
default = [] # CUDA is on by default for the main dev machine; build with
# `--no-default-features` (pnpm dev:app:cpu) on machines without the toolkit.
default = ["candle-cuda"]
candle-cuda = ["candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"] candle-cuda = ["candle-core/cuda", "candle-nn/cuda", "candle-transformers/cuda"]
[dependencies] [dependencies]
@@ -27,20 +30,86 @@ rusqlite = { version = "0.32", features = ["bundled"] }
r2d2 = "0.8" r2d2 = "0.8"
r2d2_sqlite = "0.25" r2d2_sqlite = "0.25"
sqlite-vec = "=0.1.9" sqlite-vec = "=0.1.9"
hnsw_rs = "0.3.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp", "tiff"] }
fast_image_resize = { version = "6.0.0", features = ["image"] } fast_image_resize = { version = "6.0.0", features = ["image"] }
walkdir = "2" walkdir = "2"
rayon = "1" rayon = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["rt-multi-thread"] }
chrono = { version = "0.4", features = ["serde"] } chrono = "0.4"
uuid = { version = "1", features = ["v4"] }
anyhow = "1" anyhow = "1"
log = "0.4"
ffmpeg-sidecar = "2.5.0" ffmpeg-sidecar = "2.5.0"
xxhash-rust = { version = "0.8", features = ["xxh3"] } xxhash-rust = { version = "0.8", features = ["xxh3"] }
memmap2 = "0.9"
sysinfo = "0.38.4" sysinfo = "0.38.4"
candle-core = { version = "0.10.2", features = ["cuda"] } candle-core = "0.10.2"
candle-nn = { version = "0.10.2", features = ["cuda"] } candle-nn = "0.10.2"
candle-transformers = { version = "0.10.2", features = ["cuda"] } candle-transformers = "0.10.2"
hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] } hf-hub = { version = "0.5.0", default-features = false, features = ["ureq", "native-tls"] }
tokenizers = "0.22.1" tokenizers = "0.22.1"
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "download-binaries", "copy-dylibs", "load-dynamic", "directml", "api-24", "tls-native"] }
ureq = { version = "3.3.0", default-features = false, features = ["native-tls"] }
zip = { version = "4.6.1", default-features = false, features = ["deflate"] }
csv = "1"
kamadak-exif = "0.5"
notify = "6"
tauri-plugin-notification = "2"
mozjpeg = "0.10.13"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-log = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
log = "0.4"
# ── Dev-mode performance ────────────────────────────────────────────────────
# opt-level=1 on the main crate keeps incremental compile short.
# Only the packages that are genuine hot-path bottlenecks in dev get opt-level=3
# (using "*" caused cargo to recheck all dependency fingerprints too aggressively,
# which caused spurious full rebuilds and linker conflicts).
[profile.dev]
opt-level = 1
# ML inference — without opt these run 20-50× slower than release
[profile.dev.package.candle-core]
opt-level = 3
[profile.dev.package.candle-nn]
opt-level = 3
[profile.dev.package.candle-transformers]
opt-level = 3
# ONNX runtime (WD tagger)
[profile.dev.package.ort]
opt-level = 3
[profile.dev.package.ort-sys]
opt-level = 3
# Image decode/resize workers
[profile.dev.package.image]
opt-level = 3
[profile.dev.package.fast_image_resize]
opt-level = 3
[profile.dev.package.mozjpeg]
opt-level = 3
[profile.dev.package.mozjpeg-sys]
opt-level = 3
# Parallel work scheduler
[profile.dev.package.rayon]
opt-level = 3
[profile.dev.package.rayon-core]
opt-level = 3
# Tokenisation (embedding model pre-processing)
[profile.dev.package.tokenizers]
opt-level = 3
# Hashing (duplicate finder)
[profile.dev.package.xxhash-rust]
opt-level = 3
# SQLite (frequent db calls in workers)
[profile.dev.package.rusqlite]
opt-level = 3
[profile.dev.package.libsqlite3-sys]
opt-level = 3
+6 -2
View File
@@ -15,13 +15,17 @@ fn main() {
if let Ok(path) = &cuda_path { if let Ok(path) = &cuda_path {
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled."); println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
} else { } else {
println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."); println!(
"cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."
);
} }
} else { } else {
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found."); println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads"); println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads");
println!("cargo:warning= Or build without GPU support: cargo build --no-default-features"); println!(
"cargo:warning= Or build without GPU support: cargo build --no-default-features"
);
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
} }
} }
+3
View File
@@ -13,6 +13,9 @@
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:read-files", "fs:read-files",
"fs:read-dirs", "fs:read-dirs",
"notification:default",
"updater:default",
"process:allow-restart",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because it is too large Load Diff
+1731 -42
View File
File diff suppressed because it is too large Load Diff
+1514 -58
View File
File diff suppressed because it is too large Load Diff
+63 -15
View File
@@ -20,7 +20,7 @@ fn with_text_embedder<T>(f: impl FnOnce(&ClipImageEmbedder) -> Result<T>) -> Res
.lock() .lock()
.map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?; .map_err(|_| anyhow::anyhow!("Text embedder lock poisoned"))?;
if guard.is_none() { if guard.is_none() {
println!("Initializing CLIP text embedder..."); log::info!("Initializing CLIP text embedder...");
*guard = Some(ClipImageEmbedder::new()?); *guard = Some(ClipImageEmbedder::new()?);
} }
f(guard.as_ref().unwrap()) f(guard.as_ref().unwrap())
@@ -35,13 +35,13 @@ pub struct ClipImageEmbedder {
impl ClipImageEmbedder { impl ClipImageEmbedder {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
println!("Initializing CLIP image embedder..."); log::info!("Initializing CLIP image embedder...");
let api = Api::new()?; let api = Api::new()?;
let repo = api.repo(Repo::new( let repo = api.repo(Repo::new(
"laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(), "laion/CLIP-ViT-B-32-laion2B-s34B-b79K".to_string(),
RepoType::Model, RepoType::Model,
)); ));
println!("Resolving CLIP model weights from Hugging Face cache..."); log::info!("Resolving CLIP model weights from Hugging Face cache...");
let model_path = repo.get("model.safetensors")?; let model_path = repo.get("model.safetensors")?;
let tokenizer_repo = api.repo(Repo::new( let tokenizer_repo = api.repo(Repo::new(
"openai/clip-vit-base-patch32".to_string(), "openai/clip-vit-base-patch32".to_string(),
@@ -60,7 +60,7 @@ impl ClipImageEmbedder {
}; };
let model = ClipModel::new(vb, &config)?; let model = ClipModel::new(vb, &config)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?; let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
println!("CLIP image embedder ready."); log::info!("CLIP image embedder ready.");
Ok(Self { Ok(Self {
model, model,
@@ -74,6 +74,51 @@ impl ClipImageEmbedder {
Ok(self.embed_images(&[path.to_path_buf()])?.remove(0)) Ok(self.embed_images(&[path.to_path_buf()])?.remove(0))
} }
/// Embed a cropped region of an image without writing a temp file to disk.
/// `crop_x`, `crop_y`, `crop_w`, `crop_h` are normalized 0.01.0 coordinates.
pub fn embed_image_crop(
&self,
path: &Path,
crop_x: f32,
crop_y: f32,
crop_w: f32,
crop_h: f32,
) -> Result<Vec<f32>> {
let img = image::ImageReader::open(path)?
.with_guessed_format()?
.decode()?;
let img_w = img.width() as f32;
let img_h = img.height() as f32;
let x = ((crop_x * img_w) as u32).min(img.width().saturating_sub(1));
let y = ((crop_y * img_h) as u32).min(img.height().saturating_sub(1));
let w = ((crop_w * img_w) as u32).max(1).min(img.width() - x);
let h = ((crop_h * img_h) as u32).max(1).min(img.height() - y);
let cropped = img.crop_imm(x, y, w, h);
let resized = cropped.resize_to_fill(
self.image_size as u32,
self.image_size as u32,
image::imageops::FilterType::Triangle,
);
let raw = resized.to_rgb8().into_raw();
let tensor = candle_core::Tensor::from_vec(
raw,
(self.image_size, self.image_size, 3),
&candle_core::Device::Cpu,
)?
.permute((2, 0, 1))?
.to_dtype(candle_core::DType::F32)?
.affine(2.0 / 255.0, -1.0)?;
let batch = tensor.unsqueeze(0)?.to_device(&self.device)?;
let features = self.model.get_image_features(&batch)?;
let normalized = candle_transformers::models::clip::div_l2_norm(&features)?;
Ok(normalized.get(0)?.flatten_all()?.to_vec1::<f32>()?)
}
pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> { pub fn embed_images(&self, paths: &[PathBuf]) -> Result<Vec<Vec<f32>>> {
let images = load_images(paths, self.image_size)?.to_device(&self.device)?; let images = load_images(paths, self.image_size)?.to_device(&self.device)?;
let features = self.model.get_image_features(&images)?; let features = self.model.get_image_features(&images)?;
@@ -115,7 +160,7 @@ impl ClipImageEmbedder {
let ids = enc.get_ids(); let ids = enc.get_ids();
let len = ids.len().min(max_len); let len = ids.len().min(max_len);
for j in 0..len { for j in 0..len {
flat[i * max_len + j] = ids[j] as u32; flat[i * max_len + j] = ids[j];
} }
} }
@@ -132,23 +177,25 @@ impl ClipImageEmbedder {
fn resolve_device() -> Result<Device> { fn resolve_device() -> Result<Device> {
match Device::cuda_if_available(0) { match Device::cuda_if_available(0) {
Ok(device) if device.is_cuda() => { Ok(device) if device.is_cuda() => {
println!("CLIP embedder: using CUDA GPU (device 0)."); log::info!("CLIP embedder: using CUDA GPU (device 0).");
return Ok(device); return Ok(device);
} }
Ok(_) => { Ok(_) => {
println!("CLIP embedder: no compatible CUDA GPU found — using CPU."); log::info!("CLIP embedder: no compatible CUDA GPU found — using CPU.");
} }
Err(e) => { Err(e) => {
println!("CLIP embedder: CUDA init failed ({e}) — using CPU."); log::info!("CLIP embedder: CUDA init failed ({e}) — using CPU.");
} }
} }
Ok(Device::Cpu) Ok(Device::Cpu)
} }
fn load_image(path: &Path, image_size: usize) -> Result<Tensor> { fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
let image = image::ImageReader::open(path)? // Scaled decode: CLIP only needs image_size² pixels, so decoding a large
.with_guessed_format()? // JPEG at full resolution is wasted work. Cover mode keeps the shortest
.decode()?; // side at or above image_size for the fill-crop below. Also applies EXIF
// orientation, so rotated photos embed the way they are displayed.
let image = crate::thumbnail::decode_image_scaled(path, image_size as u32, true)?;
let image = image.resize_to_fill( let image = image.resize_to_fill(
image_size as u32, image_size as u32,
image_size as u32, image_size as u32,
@@ -163,10 +210,11 @@ fn load_image(path: &Path, image_size: usize) -> Result<Tensor> {
} }
fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> { fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
let mut images = Vec::with_capacity(paths.len()); use rayon::prelude::*;
for path in paths { let images = paths
images.push(load_image(path, image_size)?); .par_iter()
} .map(|path| load_image(path, image_size))
.collect::<Result<Vec<_>>>()?;
Ok(Tensor::stack(&images, 0)?) Ok(Tensor::stack(&images, 0)?)
} }
+154
View File
@@ -0,0 +1,154 @@
use crate::vector;
use anyhow::Result;
use hnsw_rs::prelude::{DistCosine, Hnsw, Neighbour};
use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{OnceLock, RwLock};
const HNSW_MAX_CONNECTIONS: usize = 24;
const HNSW_EF_CONSTRUCTION: usize = 300;
const HNSW_EF_SEARCH: usize = 96;
struct CachedHnswIndex {
revision: String,
image_ids_by_external: Vec<i64>,
external_by_image_id: HashMap<i64, usize>,
hnsw: Hnsw<'static, f32, DistCosine>,
}
static IMAGE_HNSW_INDEX: OnceLock<RwLock<Option<CachedHnswIndex>>> = OnceLock::new();
fn cache() -> &'static RwLock<Option<CachedHnswIndex>> {
IMAGE_HNSW_INDEX.get_or_init(|| RwLock::new(None))
}
fn build_index(conn: &Connection) -> Result<CachedHnswIndex> {
// Read the revision *before* fetching embeddings so we can detect any write
// that races with the build. If the revision advances while we are building,
// the resulting index would be stale — retry until it is stable.
loop {
let revision_before = vector::get_embedding_revision(conn)?;
let embeddings = vector::get_all_image_embeddings_with_ids(conn, None)?;
let max_elements = embeddings.len().max(1);
let max_layer = 16.min((max_elements as f32).ln().trunc() as usize).max(1);
let mut hnsw = Hnsw::<f32, DistCosine>::new(
HNSW_MAX_CONNECTIONS,
max_elements,
max_layer,
HNSW_EF_CONSTRUCTION,
DistCosine {},
);
let image_ids_by_external = embeddings
.iter()
.map(|(image_id, _)| *image_id)
.collect::<Vec<_>>();
let external_by_image_id = image_ids_by_external
.iter()
.enumerate()
.map(|(external_id, image_id)| (*image_id, external_id))
.collect::<HashMap<_, _>>();
let data_with_id = embeddings
.iter()
.enumerate()
.map(|(external_id, (_, embedding))| (embedding, external_id))
.collect::<Vec<_>>();
hnsw.parallel_insert(&data_with_id);
hnsw.set_searching_mode(true);
// If the revision is unchanged the index reflects a consistent snapshot.
let revision_after = vector::get_embedding_revision(conn)?;
if revision_before == revision_after {
return Ok(CachedHnswIndex {
revision: revision_after,
image_ids_by_external,
external_by_image_id,
hnsw,
});
}
// A concurrent write advanced the revision — discard this build and retry.
}
}
fn ensure_index(conn: &Connection) -> Result<()> {
let revision = vector::get_embedding_revision(conn)?;
{
let guard = cache().read().expect("hnsw cache poisoned");
if guard.as_ref().map(|cached| cached.revision.as_str()) == Some(revision.as_str()) {
return Ok(());
}
}
let next = build_index(conn)?;
let mut guard = cache().write().expect("hnsw cache poisoned");
*guard = Some(next);
Ok(())
}
pub fn find_similar_image_matches(
conn: &Connection,
image_id: i64,
folder_id: Option<i64>,
threshold: f32,
offset: usize,
limit: usize,
) -> Result<Vec<(i64, f32)>> {
ensure_index(conn)?;
let query_embedding = match vector::get_image_embedding(conn, image_id)? {
Some(embedding) => embedding,
None => return Ok(Vec::new()),
};
// Fetch folder image IDs *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 {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter()
.map(|(id, _)| id)
.collect();
Some(ids)
} else {
None
};
let guard = cache().read().expect("hnsw cache poisoned");
let Some(cached) = guard.as_ref() else {
return Ok(Vec::new());
};
let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids {
let mut allowed_ids = image_ids
.into_iter()
.filter_map(|allowed_image_id| {
cached.external_by_image_id.get(&allowed_image_id).copied()
})
.collect::<Vec<_>>();
allowed_ids.sort_unstable();
cached
.hnsw
.search_filter(&query_embedding, knbn, HNSW_EF_SEARCH, Some(&allowed_ids))
} else {
cached.hnsw.search(&query_embedding, knbn, HNSW_EF_SEARCH)
};
let matches = neighbours
.into_iter()
.filter_map(|neighbour| {
let image_id_match = cached.image_ids_by_external.get(neighbour.d_id).copied()?;
if image_id_match == image_id || neighbour.distance > threshold {
return None;
}
Some((image_id_match, neighbour.distance))
})
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
Ok(matches)
}
+1059 -130
View File
File diff suppressed because it is too large Load Diff
+121 -5
View File
@@ -1,21 +1,49 @@
mod captioner;
mod commands; mod commands;
mod db; mod db;
mod embedder; mod embedder;
mod hnsw_index;
mod indexer; mod indexer;
mod media; mod media;
mod storage; mod storage;
mod tagger;
mod thumbnail; mod thumbnail;
mod vector; mod vector;
use tauri::Manager;
use crate::storage::StorageProfile; use crate::storage::StorageProfile;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
// Must be the first plugin: a second launch hands its args to the
// running instance and exits before anything else initializes.
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(
tauri_plugin_log::Builder::new()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("phokus".into()),
}),
])
.level(log::LevelFilter::Info)
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.build(),
)
.plugin(tauri_plugin_window_state::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
let app_dir = app let app_dir = app
.path() .path()
@@ -24,7 +52,10 @@ pub fn run() {
std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir"); std::fs::create_dir_all(&app_dir).expect("Failed to create app data dir");
media::MediaTools::ensure_installed().expect("Failed to provision FFmpeg sidecar"); // FFmpeg provisioning happens in the background so the window
// appears immediately; workers gate video jobs on readiness and
// the onboarding/Settings UI shows progress and retry.
media::spawn_ffmpeg_provision(app.handle().clone());
let db_path = app_dir.join("gallery.db"); let db_path = app_dir.join("gallery.db");
let pool = db::create_pool(&db_path).expect("Failed to create database pool"); let pool = db::create_pool(&db_path).expect("Failed to create database pool");
@@ -34,16 +65,36 @@ pub fn run() {
let conn = pool.get().expect("Failed to get connection for migration"); let conn = pool.get().expect("Failed to get connection for migration");
db::migrate(&conn).expect("Failed to run migrations"); db::migrate(&conn).expect("Failed to run migrations");
db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs"); db::reset_inflight_jobs(&conn).expect("Failed to reset inflight jobs");
let backfilled = db::backfill_embedding_jobs(&conn) let backfilled =
.expect("Failed to backfill embedding jobs"); db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 { if backfilled > 0 {
println!("Backfilled {} embedding jobs.", backfilled); log::info!("Backfilled {backfilled} embedding jobs.");
}
let (orphaned_vectors, missing_vectors) = db::repair_embedding_consistency(&conn)
.expect("Failed to repair embedding consistency");
if orphaned_vectors > 0 || missing_vectors > 0 {
log::info!(
"Repaired embedding consistency: removed {orphaned_vectors} orphaned vectors, requeued {missing_vectors} missing vectors."
);
} }
} }
let thumb_dir = app_dir.join("thumbnails"); let thumb_dir = app_dir.join("thumbnails");
std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir"); std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir");
// The asset protocol scope is no longer a blanket "**": thumbnails
// are allowed statically in tauri.conf.json, and each indexed
// folder is allowed here (and in add_folder/update_folder_path).
{
let scope = app.asset_protocol_scope();
let conn = pool.get().expect("Failed to get connection for asset scope");
for folder in db::get_folders(&conn).unwrap_or_default() {
if let Err(error) = scope.allow_directory(&folder.path, true) {
log::error!("Failed to allow asset scope for {}: {}", folder.path, error);
}
}
}
let thumbnail_worker_count = std::thread::available_parallelism() let thumbnail_worker_count = std::thread::available_parallelism()
.map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get())) .map(|parallelism| StorageProfile::Balanced.thumbnail_workers(parallelism.get()))
.unwrap_or(2); .unwrap_or(2);
@@ -58,9 +109,15 @@ pub fn run() {
} }
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone()); indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
indexer::start_embedding_worker(app.handle().clone(), pool.clone()); indexer::start_embedding_worker(app.handle().clone(), pool.clone());
// 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());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
app.manage(pool); app.manage(pool);
app.manage(media_tools); app.manage(media_tools);
app.manage(watcher_handle);
Ok(()) Ok(())
}) })
@@ -73,12 +130,71 @@ pub fn run() {
commands::reindex_folder, commands::reindex_folder,
commands::update_image_details, commands::update_image_details,
commands::find_similar_images, commands::find_similar_images,
commands::find_similar_by_region,
commands::debug_similar_images,
commands::retry_failed_embeddings, commands::retry_failed_embeddings,
commands::semantic_search_images, commands::semantic_search_images,
commands::search_images_by_tag,
commands::get_caption_model_status,
commands::get_caption_acceleration,
commands::set_caption_acceleration,
commands::get_caption_detail,
commands::set_caption_detail,
commands::prepare_caption_model,
commands::delete_caption_model,
commands::probe_caption_runtime,
commands::probe_caption_image,
commands::generate_caption_for_image,
commands::queue_caption_jobs,
commands::clear_caption_jobs,
commands::reset_generated_captions,
commands::set_generated_caption,
commands::suggest_image_tags,
commands::set_worker_paused, commands::set_worker_paused,
commands::get_worker_states, commands::get_worker_states,
commands::get_tag_cloud, commands::get_tag_cloud,
commands::get_explore_tags,
commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_tagger_model_status,
commands::get_tagger_acceleration,
commands::set_tagger_acceleration,
commands::probe_tagger_runtime,
commands::get_tagger_threshold,
commands::set_tagger_threshold,
commands::get_tagger_batch_size,
commands::set_tagger_batch_size,
commands::prepare_tagger_model,
commands::delete_tagger_model,
commands::queue_tagging_jobs,
commands::clear_tagging_jobs,
commands::get_image_tags,
commands::add_user_tag,
commands::remove_tag,
commands::search_tags_autocomplete,
commands::find_duplicates,
commands::load_duplicate_scan_cache,
commands::invalidate_duplicate_scan_cache,
commands::delete_images_from_disk,
commands::rename_folder,
commands::update_folder_path,
commands::get_tagging_queue_scope,
commands::set_tagging_queue_scope,
commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder,
commands::get_database_info,
commands::vacuum_database,
commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids,
commands::set_muted_folder_ids,
commands::get_ffmpeg_status,
commands::retry_ffmpeg_download,
commands::get_onboarding_completed,
commands::set_onboarding_completed,
commands::get_notifications_paused,
commands::set_notifications_paused,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+128 -28
View File
@@ -2,9 +2,136 @@ use anyhow::{anyhow, Result};
use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent}; use ffmpeg_sidecar::download::{auto_download_with_progress, FfmpegDownloadProgressEvent};
use ffmpeg_sidecar::ffprobe::ffprobe_path; use ffmpeg_sidecar::ffprobe::ffprobe_path;
use ffmpeg_sidecar::paths::ffmpeg_path; use ffmpeg_sidecar::paths::ffmpeg_path;
use serde::Deserialize; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
static FFMPEG_READY: AtomicBool = AtomicBool::new(false);
static FFMPEG_DOWNLOADING: AtomicBool = AtomicBool::new(false);
// Set when a provision attempt fails, so the frontend can distinguish
// "failed before the event listener attached" from "about to start".
static FFMPEG_FAILED: AtomicBool = AtomicBool::new(false);
/// True once both ffmpeg and ffprobe binaries are present on disk. Workers
/// gate video jobs on this so a missing/in-flight download never fails jobs.
pub fn ffmpeg_ready() -> bool {
FFMPEG_READY.load(Ordering::Relaxed)
}
pub fn ffmpeg_downloading() -> bool {
FFMPEG_DOWNLOADING.load(Ordering::Relaxed)
}
pub fn ffmpeg_failed() -> bool {
FFMPEG_FAILED.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, Serialize)]
pub struct FfmpegProgressPayload {
pub phase: String,
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub error: Option<String>,
}
impl FfmpegProgressPayload {
fn phase(phase: &str) -> Self {
Self {
phase: phase.to_string(),
downloaded_bytes: None,
total_bytes: None,
error: None,
}
}
}
const FFMPEG_PROGRESS_EVENT: &str = "ffmpeg-progress";
/// Provision FFmpeg in the background, streaming progress to the frontend as
/// `ffmpeg-progress` events. Idempotent: re-invoking while a download is in
/// flight is a no-op, and re-invoking after success only re-emits `done`.
pub fn spawn_ffmpeg_provision(app: AppHandle) {
// Fast path: binaries already on disk (previous run, or a manual install).
if ffmpeg_path().exists() && ffprobe_path().exists() {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
return;
}
if FFMPEG_DOWNLOADING
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return; // a download is already running
}
FFMPEG_FAILED.store(false, Ordering::SeqCst);
std::thread::spawn(move || {
// The Downloading callback fires very frequently; throttle emissions.
// Cell because the download callback is Fn, not FnMut.
let last_emit = std::cell::Cell::new(Instant::now() - Duration::from_secs(1));
let result = auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
log::info!("Downloading bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("starting"),
);
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
if last_emit.get().elapsed() >= Duration::from_millis(250) {
last_emit.set(Instant::now());
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "downloading".to_string(),
downloaded_bytes: Some(downloaded_bytes),
total_bytes: Some(total_bytes),
error: None,
},
);
}
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
log::info!("Unpacking bundled FFmpeg...");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload::phase("unpacking"),
);
}
FfmpegDownloadProgressEvent::Done => {
log::info!("Bundled FFmpeg ready.");
}
});
FFMPEG_DOWNLOADING.store(false, Ordering::SeqCst);
match result {
Ok(()) => {
FFMPEG_READY.store(true, Ordering::Relaxed);
let _ = app.emit(FFMPEG_PROGRESS_EVENT, FfmpegProgressPayload::phase("done"));
}
Err(error) => {
FFMPEG_FAILED.store(true, Ordering::SeqCst);
log::error!("FFmpeg provisioning failed: {error}");
let _ = app.emit(
FFMPEG_PROGRESS_EVENT,
FfmpegProgressPayload {
phase: "error".to_string(),
downloaded_bytes: None,
total_bytes: None,
error: Some(error.to_string()),
},
);
}
}
});
}
// On Windows, GUI apps spawn subprocesses with a visible console window by default. // On Windows, GUI apps spawn subprocesses with a visible console window by default.
// CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation. // CREATE_NO_WINDOW suppresses that for every ffmpeg/ffprobe invocation.
@@ -36,33 +163,6 @@ impl MediaTools {
} }
} }
pub fn ensure_installed() -> Result<()> {
// Skip download entirely if both binaries are already present.
if ffmpeg_path().exists() && ffprobe_path().exists() {
return Ok(());
}
auto_download_with_progress(|event| match event {
FfmpegDownloadProgressEvent::Starting => {
println!("Downloading bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Downloading {
total_bytes,
downloaded_bytes,
} => {
println!(
"Downloading bundled FFmpeg: {}/{} bytes",
downloaded_bytes, total_bytes
);
}
FfmpegDownloadProgressEvent::UnpackingArchive => {
println!("Unpacking bundled FFmpeg...");
}
FfmpegDownloadProgressEvent::Done => {
println!("Bundled FFmpeg ready.");
}
})
}
pub fn ffmpeg_command(&self) -> Command { pub fn ffmpeg_command(&self) -> Command {
let mut cmd = Command::new(&self.ffmpeg_path); let mut cmd = Command::new(&self.ffmpeg_path);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
+2 -2
View File
@@ -1,4 +1,4 @@
use std::path::{Path, PathBuf}; use std::path::Path;
use sysinfo::{DiskKind, Disks}; use sysinfo::{DiskKind, Disks};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -107,7 +107,7 @@ pub fn detect_storage_profile(path: &Path) -> StorageProfile {
} }
} }
fn fallback_profile_for_path(path: &PathBuf) -> StorageProfile { fn fallback_profile_for_path(path: &Path) -> StorageProfile {
let path_str = path.to_string_lossy().to_lowercase(); let path_str = path.to_string_lossy().to_lowercase();
if path_str.starts_with("\\\\") { if path_str.starts_with("\\\\") {
return StorageProfile::Conservative; return StorageProfile::Conservative;
+691
View File
@@ -0,0 +1,691 @@
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 serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
pub const WD_TAGGER_MODEL_ID: &str = "SmilingWolf/wd-swinv2-tagger-v3";
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";
// 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] = &[
"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.
// Category 0 = general, category 4 = character.
// Category 9 = rating (explicit/questionable/sensitive/general) used for
// `ai_rating` but NOT emitted as individual tags.
const GENERAL_CATEGORY: u8 = 0;
const CHARACTER_CATEGORY: u8 = 4;
const RATING_CATEGORY: u8 = 9;
pub const DEFAULT_THRESHOLD: f32 = 0.35;
pub const DEFAULT_MAX_TAGS: usize = 30;
/// 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);
// ---------------------------------------------------------------------------
// Settings types
// ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum TaggerAcceleration {
Auto,
Cpu,
Directml,
}
impl TaggerAcceleration {
fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Cpu => "cpu",
Self::Directml => "directml",
}
}
}
impl Default for TaggerAcceleration {
fn default() -> Self {
Self::Auto
}
}
// ---------------------------------------------------------------------------
// Status / probe types exposed to the frontend
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct TaggerModelStatus {
pub model_id: &'static str,
pub model_name: &'static str,
pub local_dir: String,
pub ready: bool,
pub missing_files: Vec<String>,
}
#[derive(Clone, Serialize)]
pub struct TaggerModelProgress {
pub total_files: usize,
pub completed_files: usize,
pub current_file: Option<String>,
// Byte progress for the file currently downloading. None for files whose
// size isn't known up front (or between files).
pub downloaded_bytes: Option<u64>,
pub total_bytes: Option<u64>,
pub done: bool,
}
// ---------------------------------------------------------------------------
// Runtime probe types exposed to the frontend
// ---------------------------------------------------------------------------
#[derive(Serialize)]
pub struct TaggerRuntimeProbe {
pub ready: bool,
pub acceleration: TaggerAcceleration,
pub session: TaggerRuntimeSessionProbe,
}
#[derive(Serialize)]
pub struct TaggerRuntimeSessionProbe {
pub file: &'static str,
pub inputs: Vec<String>,
pub outputs: Vec<String>,
}
// ---------------------------------------------------------------------------
// Tag record returned to callers
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize)]
pub struct TagResult {
pub tag: String,
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaggerOutput {
pub tags: Vec<TagResult>,
/// Highest-scoring rating label: "general" | "sensitive" | "questionable" | "explicit"
pub rating: String,
}
// ---------------------------------------------------------------------------
// Internal label table built from selected_tags.csv
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct TagEntry {
name: String,
category: u8,
}
// ---------------------------------------------------------------------------
// Path helpers
// ---------------------------------------------------------------------------
pub fn model_dir(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("models").join("wd-swinv2-tagger-v3")
}
// ---------------------------------------------------------------------------
// Settings persistence
// ---------------------------------------------------------------------------
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 {
return TaggerAcceleration::default();
};
match value.trim().to_ascii_lowercase().as_str() {
"cpu" => TaggerAcceleration::Cpu,
"directml" => TaggerAcceleration::Directml,
_ => TaggerAcceleration::Auto,
}
}
pub fn set_tagger_acceleration(
app_data_dir: &Path,
acceleration: TaggerAcceleration,
) -> Result<TaggerAcceleration> {
let path = app_data_dir.join(TAGGER_ACCELERATION_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, acceleration.as_str())?;
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(acceleration)
}
pub fn tagger_threshold(app_data_dir: &Path) -> f32 {
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
return DEFAULT_THRESHOLD;
};
value
.trim()
.parse::<f32>()
.unwrap_or(DEFAULT_THRESHOLD)
.clamp(0.01, 1.0)
}
pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32> {
let clamped = threshold.clamp(0.01, 1.0);
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, clamped.to_string())?;
TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed);
Ok(clamped)
}
pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
let Ok(value) = std::fs::read_to_string(path) else {
return 8;
};
value.trim().parse::<usize>().unwrap_or(8).clamp(1, 100)
}
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
let clamped = batch_size.clamp(1, 100);
let path = app_data_dir.join(TAGGER_BATCH_SIZE_FILE);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, clamped.to_string())?;
Ok(clamped)
}
// ---------------------------------------------------------------------------
// Model status / download
// ---------------------------------------------------------------------------
pub fn tagger_model_status(app_data_dir: &Path) -> TaggerModelStatus {
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
.iter()
.filter(|file| {
let path = if file.starts_with("onnxruntime/") {
caption_model_dir.join(file)
} else {
local_dir.join(file)
};
!path.exists()
})
.map(|file| (*file).to_string())
.collect::<Vec<_>>();
TaggerModelStatus {
model_id: WD_TAGGER_MODEL_ID,
model_name: WD_TAGGER_MODEL_NAME,
local_dir: local_dir.to_string_lossy().to_string(),
ready: missing_files.is_empty(),
missing_files,
}
}
pub fn prepare_tagger_model_with_progress(
app_data_dir: &Path,
emit_progress: impl Fn(TaggerModelProgress),
) -> Result<TaggerModelStatus> {
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 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
.iter()
.filter(|file| !local_dir.join(file).exists())
.count();
let total_files = dll_count + model_pending;
let mut completed_files = 0usize;
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: None,
downloaded_bytes: None,
total_bytes: None,
done: total_files == 0,
});
// ── ONNX runtime DLLs (small, but non-trivial on a slow link) ──
{
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::provision_onnx_runtime_with_progress(
&caption_model_dir,
|label, downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: Some(format!("ONNX Runtime: {label}")),
downloaded_bytes: Some(downloaded),
total_bytes: total,
done: false,
});
}
},
)?;
completed_files += dll_count;
}
log::info!("Tagger: ONNX runtime DLLs ready; initializing runtime");
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
log::info!("Tagger: runtime initialized; downloading model files");
// ── Tagger model files (model.onnx is ~446 MB) ──
// Download directly from the resolved URL with our resilient downloader
// (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));
for file in DOWNLOAD_FILES {
let destination = local_dir.join(file);
if destination.exists() {
continue;
}
let url = repo.url(file);
let label = (*file).to_string();
let mut last_emit = Instant::now() - std::time::Duration::from_secs(1);
crate::captioner::download_file_resilient(&url, &destination, |downloaded, total| {
if last_emit.elapsed() >= std::time::Duration::from_millis(200) {
last_emit = Instant::now();
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: Some(label.clone()),
downloaded_bytes: Some(downloaded),
total_bytes: total,
done: false,
});
}
})?;
completed_files += 1;
}
emit_progress(TaggerModelProgress {
total_files,
completed_files,
current_file: None,
downloaded_bytes: None,
total_bytes: None,
done: true,
});
Ok(tagger_model_status(app_data_dir))
}
pub fn delete_tagger_model(app_data_dir: &Path) -> Result<TaggerModelStatus> {
let local_dir = model_dir(app_data_dir);
if local_dir.exists() {
std::fs::remove_dir_all(&local_dir)?;
}
Ok(tagger_model_status(app_data_dir))
}
pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
let status = tagger_model_status(app_data_dir);
if !status.ready {
anyhow::bail!(
"WD Tagger model is missing {} required file(s): {}",
status.missing_files.len(),
status.missing_files.join(", ")
);
}
let local_dir = model_dir(app_data_dir);
let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft");
crate::captioner::ensure_onnx_runtime(&caption_model_dir)?;
let acceleration = tagger_acceleration(app_data_dir);
let model_path = local_dir.join("model.onnx");
// Verify that the model file exists and has non-zero size before trying
// to create an ORT session (better error message on corruption).
let metadata = std::fs::metadata(&model_path)?;
if metadata.len() == 0 {
anyhow::bail!("model.onnx is empty");
}
// Actually create a session to verify the EP loads correctly.
let loaded_acceleration = match acceleration {
TaggerAcceleration::Cpu => {
create_tagger_session(&model_path, TaggerAcceleration::Cpu)?;
TaggerAcceleration::Cpu
}
TaggerAcceleration::Auto => {
// Try DirectML explicitly; if it fails the real session would have
// fallen back to CPU silently — report what would actually run.
let directml_ok =
create_tagger_session(&model_path, TaggerAcceleration::Directml).is_ok();
if directml_ok {
TaggerAcceleration::Directml
} else {
create_tagger_session(&model_path, TaggerAcceleration::Cpu)?;
TaggerAcceleration::Cpu
}
}
TaggerAcceleration::Directml => {
create_tagger_session(&model_path, TaggerAcceleration::Directml)?;
TaggerAcceleration::Directml
}
};
Ok(TaggerRuntimeProbe {
ready: true,
acceleration: loaded_acceleration,
session: TaggerRuntimeSessionProbe {
file: "model.onnx",
inputs: vec!["pixel_values".to_string()],
outputs: vec![format!("output [EP: {:?}]", loaded_acceleration)],
},
})
}
// ---------------------------------------------------------------------------
// Top-level inference entry point
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Tagger implementation
// ---------------------------------------------------------------------------
pub struct WdTagger {
session: Session,
labels: Vec<TagEntry>,
threshold: f32,
input_size: usize,
}
impl WdTagger {
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!(
"WD tagger model is missing {} required file(s): {}",
status.missing_files.len(),
status.missing_files.join(", ")
);
}
let local_dir = model_dir(app_data_dir);
// The ONNX runtime DLLs are shared with the captioner; use the
// captioner's shared ORT init lock to avoid double-initialisation.
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 = tagger_threshold(app_data_dir);
let model_path = local_dir.join("model.onnx");
let labels_path = local_dir.join("selected_tags.csv");
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 = {
let inputs = session.inputs();
let dim = 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 })
} else {
None
}
})
.unwrap_or(448);
dim
};
let labels = load_labels(&labels_path)?;
log::info!(
"WD tagger loaded in {:?} ({} labels, input {}x{}, {:?} acceleration)",
started_at.elapsed(),
labels.len(),
input_size,
input_size,
acceleration,
);
Ok(Self {
session,
labels,
threshold,
input_size,
})
}
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;
let input = Tensor::from_array((
[batch_size, self.input_size, self.input_size, 3usize],
image_array.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 (_, probabilities) = outputs[0]
.try_extract_tensor::<f32>()
.map_err(|error| anyhow::anyhow!("{error}"))?;
let probs: &[f32] = probabilities;
if probs.len() != self.labels.len() {
anyhow::bail!(
"Model output length {} does not match label count {}",
probs.len(),
self.labels.len()
);
}
// Collect rating scores (category 9) - pick the argmax as the rating.
let rating = self
.labels
.iter()
.zip(probs.iter())
.filter(|(entry, _)| entry.category == RATING_CATEGORY)
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.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
.iter()
.zip(probs.iter())
.filter(|(entry, prob)| {
(entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY)
&& **prob >= self.threshold
})
.map(|(entry, prob)| TagResult {
tag: entry.name.clone(),
confidence: *prob,
})
.collect();
tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence));
tags.truncate(max_tags);
log::info!(
"WD tagger: {} tags (threshold={}, rating={}) in {:?} for {}",
tags.len(),
self.threshold,
rating,
started_at.elapsed(),
image_path.display(),
);
Ok(TaggerOutput { tags, rating })
}
}
// ---------------------------------------------------------------------------
// Session creation mirrors captioner's `create_session`
// ---------------------------------------------------------------------------
fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Result<Session> {
let builder = Session::builder().map_err(|error| anyhow::anyhow!("{error}"))?;
let builder = builder
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let use_directml = matches!(
acceleration,
TaggerAcceleration::Auto | TaggerAcceleration::Directml
);
let builder = builder
.with_memory_pattern(!use_directml)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let builder = builder
.with_parallel_execution(false)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let builder = builder
.with_intra_threads(1)
.map_err(|error| anyhow::anyhow!("{error}"))?;
let mut builder = match acceleration {
TaggerAcceleration::Cpu => {
log::info!("WD tagger: using CPU execution provider");
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");
error.recover()
}),
TaggerAcceleration::Directml => builder
.with_execution_providers([ep::DirectML::default().build().error_on_failure()])
.map_err(|error| anyhow::anyhow!("{error}"))?,
};
let session = builder
.commit_from_file(path)
.map_err(|error| anyhow::anyhow!("{error}"))?;
Ok(session)
}
// ---------------------------------------------------------------------------
// Label loading
// ---------------------------------------------------------------------------
fn load_labels(path: &Path) -> Result<Vec<TagEntry>> {
let mut reader = csv::Reader::from_path(path)?;
let mut entries = Vec::new();
for result in reader.records() {
let record = result?;
// CSV columns: tag_id, name, category, count
let name = record
.get(1)
.ok_or_else(|| anyhow::anyhow!("Missing name column in selected_tags.csv"))?
.replace('_', " ");
let category: u8 = record
.get(2)
.ok_or_else(|| anyhow::anyhow!("Missing category column in selected_tags.csv"))?
.parse()
.unwrap_or(0);
entries.push(TagEntry { name, category });
}
if entries.is_empty() {
anyhow::bail!("selected_tags.csv is empty or could not be parsed");
}
Ok(entries)
}
// ---------------------------------------------------------------------------
// 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).
// ---------------------------------------------------------------------------
fn preprocess_image(image_path: &Path, target_size: usize) -> Result<Vec<f32>> {
let image = ImageReader::open(image_path)?.decode()?;
// Composite any alpha channel onto a white background.
let image_rgba = image.to_rgba8();
let (width, height) = image_rgba.dimensions();
let mut canvas_rgba =
image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0);
let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8();
// Pad to 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(
&square,
target_size as u32,
target_size as u32,
FilterType::CatmullRom,
);
// Flatten to (H, W, 3) float32 in BGR order, values in [0, 255].
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;
// BGR order
pixel_values[base] = f32::from(pixel[2]); // B
pixel_values[base + 1] = f32::from(pixel[1]); // G
pixel_values[base + 2] = f32::from(pixel[0]); // R
}
Ok(pixel_values)
}
+152 -16
View File
@@ -31,29 +31,26 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
}); });
} }
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?; let img = decode_for_thumbnail(image_path)?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
let src = image::DynamicImage::ImageRgba8(img.into_rgba8()); // RGB8 throughout: JPEG output has no alpha, so decoding/resizing RGBA
// only to flatten at encode time wastes a third of the bandwidth.
let src = image::DynamicImage::ImageRgb8(img.into_rgb8());
let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE); let (dst_width, dst_height) = fit_dimensions(src.width(), src.height(), THUMB_SIZE);
let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap()); let mut dst = fir::images::Image::new(dst_width, dst_height, src.pixel_type().unwrap());
let mut resizer = fir::Resizer::new(); let mut resizer = fir::Resizer::new();
let options = fir::ResizeOptions::new() let options = fir::ResizeOptions::new()
.resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::Lanczos3)); .resize_alg(fir::ResizeAlg::Convolution(fir::FilterType::CatmullRom));
resizer.resize(&src, &mut dst, Some(&options))?; resizer.resize(&src, &mut dst, Some(&options))?;
let thumb = image::RgbaImage::from_raw(dst_width, dst_height, dst.buffer().to_vec()) let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?; .ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
if let Some(parent) = out_path.parent() { if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let thumb = image::DynamicImage::ImageRgba8(thumb).into_rgb8();
let mut output_file = std::fs::File::create(&out_path)?; let mut output_file = std::fs::File::create(&out_path)?;
let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82); let mut encoder = JpegEncoder::new_with_quality(&mut output_file, 82);
encoder.encode_image(&thumb)?; encoder.encode_image(&thumb)?;
@@ -64,6 +61,98 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
}) })
} }
/// Decodes an image for thumbnailing, with EXIF orientation already applied.
fn decode_for_thumbnail(image_path: &Path) -> Result<image::DynamicImage> {
decode_image_scaled(image_path, THUMB_SIZE, false)
}
/// Decodes an image at reduced resolution, with EXIF orientation applied.
///
/// JPEGs take a fast path that decodes at reduced resolution (DCT scaling),
/// which skips most of the IDCT/upsampling work for large photos. Anything
/// that path can't handle falls back to the `image` crate at full size.
///
/// `cover` selects which side must stay at or above `target`: `false` keeps
/// the longest side (for aspect-fit consumers), `true` keeps the shortest
/// side (for fill-crop consumers like the CLIP preprocessor).
pub fn decode_image_scaled(
image_path: &Path,
target: u32,
cover: bool,
) -> Result<image::DynamicImage> {
if is_jpeg(image_path) {
if let Some(img) = decode_jpeg_scaled(image_path, target, cover) {
return Ok(img);
}
}
let reader = image::ImageReader::open(image_path)?.with_guessed_format()?;
let mut decoder = reader.into_decoder()?;
let orientation = decoder.orientation()?;
let mut img = image::DynamicImage::from_decoder(decoder)?;
img.apply_orientation(orientation);
Ok(img)
}
fn is_jpeg(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg"))
}
/// Decodes a JPEG at the smallest DCT scale that still covers `target`.
/// Returns `None` on any failure (corrupt file, CMYK without conversion
/// support, etc.) so the caller can fall back to the generic decoder.
/// mozjpeg reports errors by panicking, hence the `catch_unwind`.
fn decode_jpeg_scaled(image_path: &Path, target: u32, cover: bool) -> Option<image::DynamicImage> {
let path = image_path.to_path_buf();
let decoded = std::panic::catch_unwind(move || -> std::io::Result<(Vec<u8>, u32, u32)> {
let mut decompress = mozjpeg::Decompress::new_path(&path)?;
let (width, height) = decompress.size();
let reference = if cover {
width.min(height)
} else {
width.max(height)
};
decompress.scale(scale_numerator(reference, target));
let mut started = decompress.rgb()?;
let (out_width, out_height) = (started.width() as u32, started.height() as u32);
let pixels = started.read_scanlines::<u8>()?;
started.finish()?;
Ok((pixels, out_width, out_height))
})
.ok()?
.ok()?;
let (pixels, width, height) = decoded;
let buffer = image::RgbImage::from_raw(width, height, pixels)?;
let mut img = image::DynamicImage::ImageRgb8(buffer);
if let Some(orientation) = exif_orientation(image_path) {
img.apply_orientation(orientation);
}
Some(img)
}
/// Smallest numerator (of /8) that keeps `reference` at or above `target`,
/// so the subsequent resize never upscales.
fn scale_numerator(reference: usize, target: u32) -> u8 {
for numerator in 1..=8u8 {
if reference * numerator as usize / 8 >= target as usize {
return numerator;
}
}
8
}
fn exif_orientation(path: &Path) -> Option<image::metadata::Orientation> {
let file = std::fs::File::open(path).ok()?;
let mut reader = std::io::BufReader::new(file);
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
let field = exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY)?;
let value = field.value.get_uint(0)?;
image::metadata::Orientation::from_exif(value as u8)
}
pub fn generate_video_thumbnail( pub fn generate_video_thumbnail(
tools: &MediaTools, tools: &MediaTools,
video_path: &Path, video_path: &Path,
@@ -88,6 +177,8 @@ pub fn generate_video_thumbnail(
let attempts: [&[&str]; 3] = [ let attempts: [&[&str]; 3] = [
&[ &[
"-y", "-y",
"-threads",
"2",
"-ss", "-ss",
"00:00:00.000", "00:00:00.000",
"-i", "-i",
@@ -95,13 +186,15 @@ pub fn generate_video_thumbnail(
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
], ],
&[ &[
"-y", "-y",
"-threads",
"2",
"-ss", "-ss",
"00:00:00.250", "00:00:00.250",
"-i", "-i",
@@ -109,19 +202,21 @@ pub fn generate_video_thumbnail(
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
], ],
&[ &[
"-y", "-y",
"-threads",
"2",
"-i", "-i",
path_str.as_ref(), path_str.as_ref(),
"-frames:v", "-frames:v",
"1", "1",
"-vf", "-vf",
"thumbnail,scale=320:-1:force_original_aspect_ratio=decrease", "scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v", "-q:v",
"4", "4",
&output_path, &output_path,
@@ -142,9 +237,7 @@ pub fn generate_video_thumbnail(
} }
Err(anyhow!( Err(anyhow!(
"ffmpeg failed generating poster for {}: {}", "ffmpeg failed generating poster for {path_str}: {last_error}"
path_str,
last_error
)) ))
} }
@@ -158,7 +251,7 @@ pub fn video_poster_path(cache_dir: &Path, video_path: &str) -> PathBuf {
fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf { fn thumb_path_with_ext(cache_dir: &Path, input_path: &str, extension: &str) -> PathBuf {
let hash = hash_path(input_path); let hash = hash_path(input_path);
cache_dir.join(format!("{}.{}", hash, extension)) cache_dir.join(format!("{hash}.{extension}"))
} }
fn hash_path(s: &str) -> String { fn hash_path(s: &str) -> String {
@@ -178,3 +271,46 @@ fn fit_dimensions(width: u32, height: u32, max_size: u32) -> (u32, u32) {
(scaled_width.max(1), max_size) (scaled_width.max(1), max_size)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_numerator_picks_smallest_sufficient() {
assert_eq!(scale_numerator(6000, THUMB_SIZE), 1);
assert_eq!(scale_numerator(640, THUMB_SIZE), 4);
assert_eq!(scale_numerator(320, THUMB_SIZE), 8);
assert_eq!(scale_numerator(100, THUMB_SIZE), 8);
// Cover mode reference: shortest side must reach 224 for CLIP.
assert_eq!(scale_numerator(1200, 224), 2);
}
#[test]
fn jpeg_fast_path_decodes_at_reduced_resolution() {
let dir = std::env::temp_dir().join("phokus-thumb-test");
std::fs::create_dir_all(&dir).unwrap();
let src_path = dir.join("large.jpg");
let img =
image::RgbImage::from_fn(1600, 1200, |x, _| image::Rgb([(x % 256) as u8, 64, 128]));
img.save(&src_path).unwrap();
// 1600 max dim -> numerator 2 -> 400x300 decode output
let decoded = decode_jpeg_scaled(&src_path, THUMB_SIZE, false)
.expect("fast path should handle plain JPEG");
assert_eq!((decoded.width(), decoded.height()), (400, 300));
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
let covered =
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
assert_eq!((covered.width(), covered.height()), (400, 300));
let cache = dir.join("cache");
let _ = std::fs::remove_file(thumb_path(&cache, &src_path.to_string_lossy()));
let result = generate_image_thumbnail(&src_path, &cache).unwrap();
assert_eq!(result.width, Some(1600));
assert_eq!(result.height, Some(1200));
let (thumb_w, thumb_h) = image::image_dimensions(&result.path).unwrap();
assert_eq!((thumb_w, thumb_h), (320, 240));
}
}
+327 -13
View File
@@ -1,5 +1,5 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use rusqlite::{ffi::sqlite3_auto_extension, Connection}; use rusqlite::{ffi::sqlite3_auto_extension, Connection, Error as SqliteError};
use sqlite_vec::sqlite3_vec_init; use sqlite_vec::sqlite3_vec_init;
use std::sync::Once; use std::sync::Once;
@@ -10,7 +10,14 @@ static SQLITE_VEC_INIT: Once = Once::new();
pub fn register_sqlite_vec() { pub fn register_sqlite_vec() {
SQLITE_VEC_INIT.call_once(|| unsafe { SQLITE_VEC_INIT.call_once(|| unsafe {
sqlite3_auto_extension(Some(std::mem::transmute(sqlite3_vec_init as *const ()))); sqlite3_auto_extension(Some(std::mem::transmute::<
*const (),
unsafe extern "C" fn(
*mut rusqlite::ffi::sqlite3,
*mut *mut std::os::raw::c_char,
*const rusqlite::ffi::sqlite3_api_routines,
) -> i32,
>(sqlite3_vec_init as *const ())));
}); });
} }
@@ -18,9 +25,13 @@ pub fn migrate(conn: &Connection) -> Result<()> {
conn.execute_batch(&format!( conn.execute_batch(&format!(
"CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0( "CREATE VIRTUAL TABLE IF NOT EXISTS image_vec USING vec0(
image_id INTEGER PRIMARY KEY, image_id INTEGER PRIMARY KEY,
embedding FLOAT[{}] distance_metric=cosine embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
);", );
CLIP_VECTOR_DIM
CREATE VIRTUAL TABLE IF NOT EXISTS caption_vec USING vec0(
image_id INTEGER PRIMARY KEY,
embedding FLOAT[{CLIP_VECTOR_DIM}] distance_metric=cosine
);"
))?; ))?;
Ok(()) Ok(())
} }
@@ -28,6 +39,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
#[allow(dead_code)] #[allow(dead_code)]
pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> { pub fn delete_embedding(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?; conn.execute("DELETE FROM image_vec WHERE image_id = ?1", [image_id])?;
// Advance the revision so any cached HNSW index is invalidated after deletions.
conn.execute(
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
ON CONFLICT(key) DO UPDATE SET value = value + 1",
[],
)?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_caption_embedding(conn: &Connection, image_id: i64) -> Result<()> {
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
Ok(()) Ok(())
} }
@@ -50,26 +73,74 @@ pub fn upsert_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) ->
Ok(()) Ok(())
} }
pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) -> Result<Vec<i64>> { #[allow(dead_code)]
let embedding: Vec<u8> = conn.query_row( pub fn upsert_caption_embedding(conn: &Connection, image_id: i64, embedding: &[f32]) -> Result<()> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
conn.execute("DELETE FROM caption_vec WHERE image_id = ?1", [image_id])?;
conn.execute(
"INSERT INTO caption_vec (image_id, embedding) VALUES (?1, ?2)",
(&image_id, &packed),
)?;
Ok(())
}
pub fn find_similar_image_ids(
conn: &Connection,
image_id: i64,
limit: usize,
folder_id: Option<i64>,
) -> Result<Vec<i64>> {
let embedding: Vec<u8> = match conn.query_row(
"SELECT embedding FROM image_vec WHERE image_id = ?1", "SELECT embedding FROM image_vec WHERE image_id = ?1",
[image_id], [image_id],
|row| row.get(0), |row| row.get(0),
)?; ) {
Ok(embedding) => embedding,
Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
if let Some(folder_id) = folder_id {
// Brute-force cosine scan scoped to the folder — avoids the KNN k=4096 limit
// and returns exact nearest neighbours within the folder.
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN images i ON i.id = v.image_id
WHERE i.folder_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((&embedding, folder_id, image_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
return Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?);
}
// Global KNN search (no folder filter) — use the ANN index.
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT image_id "SELECT image_id
FROM image_vec FROM image_vec
WHERE embedding MATCH vec_f32(?1) WHERE embedding MATCH vec_f32(?1)
AND k = ?2", AND k = ?2",
)?; )?;
let rows = stmt.query_map((&embedding, (limit as i64) + 1), |row| row.get::<_, i64>(0))?; let rows = stmt
.query_map((&embedding, (limit + 1) as i64), |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
let mut ids = Vec::new(); let mut ids = Vec::new();
for row in rows { for row in rows {
let candidate_id = row?; if row != image_id {
if candidate_id != image_id { ids.push(row);
ids.push(candidate_id);
} }
if ids.len() >= limit { if ids.len() >= limit {
break; break;
@@ -78,6 +149,106 @@ pub fn find_similar_image_ids(conn: &Connection, image_id: i64, limit: usize) ->
Ok(ids) Ok(ids)
} }
// pub fn find_similar_image_matches(
// conn: &Connection,
// image_id: i64,
// folder_id: Option<i64>,
// threshold: f32,
// offset: usize,
// limit: usize,
// ) -> Result<Vec<(i64, f32)>> {
// let embedding: Vec<u8> = match conn.query_row(
// "SELECT embedding FROM image_vec WHERE image_id = ?1",
// [image_id],
// |row| row.get(0),
// ) {
// Ok(embedding) => embedding,
// Err(SqliteError::QueryReturnedNoRows) => return Ok(Vec::new()),
// Err(error) => return Err(error.into()),
// };
// let query = match folder_id {
// Some(_) => {
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
// FROM image_vec v
// JOIN images i ON i.id = v.image_id
// WHERE i.folder_id = ?2
// AND v.image_id != ?3
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?4
// ORDER BY distance ASC
// LIMIT ?5 OFFSET ?6"
// }
// None => {
// "SELECT v.image_id, vec_distance_cosine(v.embedding, vec_f32(?1)) AS distance
// FROM image_vec v
// WHERE v.image_id != ?2
// AND vec_distance_cosine(v.embedding, vec_f32(?1)) <= ?3
// ORDER BY distance ASC
// LIMIT ?4 OFFSET ?5"
// }
// };
// let mut stmt = conn.prepare(query)?;
// match folder_id {
// Some(folder_id) => Ok(stmt
// .query_map(
// (
// &embedding,
// folder_id,
// image_id,
// threshold,
// limit as i64,
// offset as i64,
// ),
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
// )?
// .collect::<rusqlite::Result<Vec<_>>>()?),
// None => Ok(stmt
// .query_map(
// (&embedding, image_id, threshold, limit as i64, offset as i64),
// |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?)),
// )?
// .collect::<rusqlite::Result<Vec<_>>>()?),
// }
// }
pub fn get_image_embedding(conn: &Connection, image_id: i64) -> Result<Option<Vec<f32>>> {
let embedding: Result<Vec<u8>, rusqlite::Error> = conn.query_row(
"SELECT embedding FROM image_vec WHERE image_id = ?1",
[image_id],
|row| row.get(0),
);
match embedding {
Ok(bytes) => Ok(Some(unpack_f32(&bytes))),
Err(SqliteError::QueryReturnedNoRows) => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn get_embedding_revision(conn: &Connection) -> Result<String> {
// Use the monotonically incremented app_kv counter so that two embeddings
// saved within the same clock second still advance the revision, preventing
// the HNSW cache from serving stale vectors.
let revision: i64 = conn
.query_row(
"SELECT COALESCE((SELECT value FROM app_kv WHERE key = 'embedding_revision'), 0)",
[],
|row| row.get(0),
)
.unwrap_or(0);
Ok(revision.to_string())
}
// fn image_ids_for_folder(
// conn: &Connection,
// folder_id: i64,
// ) -> Result<std::collections::HashSet<i64>> {
// let mut stmt = conn.prepare("SELECT id FROM images WHERE folder_id = ?1")?;
// let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?;
// Ok(rows.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?)
// }
/// Returns all stored image embeddings with their image IDs, optionally filtered to one folder. /// Returns all stored image embeddings with their image IDs, optionally filtered to one folder.
/// Each entry is `(image_id, normalized_f32_embedding)`. /// Each entry is `(image_id, normalized_f32_embedding)`.
pub fn get_all_image_embeddings_with_ids( pub fn get_all_image_embeddings_with_ids(
@@ -155,9 +326,152 @@ pub fn search_image_ids_by_embedding(
Ok(ids) Ok(ids)
} }
/// Brute-force cosine search scoped to a single folder, ordered by ascending distance.
/// Used for region-based similarity search where we want folder-scoped results.
pub fn search_image_ids_by_embedding_in_folder(
conn: &Connection,
embedding: &[f32],
folder_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 images i ON i.id = v.image_id
WHERE i.folder_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, folder_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,
embedding: &[f32],
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 mut stmt = conn.prepare(
"SELECT image_id
FROM caption_vec
WHERE embedding MATCH vec_f32(?1)
AND k = ?2",
)?;
let rows = stmt.query_map((&packed, limit as i64), |row| row.get::<_, i64>(0))?;
let mut ids = Vec::new();
for row in rows {
ids.push(row?);
if ids.len() >= limit {
break;
}
}
Ok(ids)
}
pub fn count_image_vectors(conn: &Connection) -> Result<i64> {
conn.query_row("SELECT COUNT(*) FROM image_vec", [], |row| row.get(0))
.map_err(Into::into)
}
#[allow(dead_code)]
pub fn count_caption_vectors(conn: &Connection) -> Result<i64> {
conn.query_row("SELECT COUNT(*) FROM caption_vec", [], |row| row.get(0))
.map_err(Into::into)
}
pub fn delete_orphaned_embeddings(conn: &Connection) -> Result<usize> {
let image_ids = {
let mut stmt = conn.prepare("SELECT id FROM images")?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
rows
};
let vector_ids = {
let mut stmt = conn.prepare("SELECT image_id FROM image_vec")?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
rows
};
let orphaned_ids = vector_ids
.into_iter()
.filter(|image_id| !image_ids.contains(image_id))
.collect::<Vec<_>>();
for image_id in &orphaned_ids {
delete_embedding(conn, *image_id)?;
}
Ok(orphaned_ids.len())
}
#[allow(dead_code)]
pub fn delete_orphaned_caption_embeddings(conn: &Connection) -> Result<usize> {
let image_ids = {
let mut stmt = conn.prepare("SELECT id FROM images")?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<std::collections::HashSet<_>>>()?;
rows
};
let vector_ids = {
let mut stmt = conn.prepare("SELECT image_id FROM caption_vec")?;
let rows = stmt
.query_map([], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
rows
};
let orphaned_ids = vector_ids
.into_iter()
.filter(|image_id| !image_ids.contains(image_id))
.collect::<Vec<_>>();
for image_id in &orphaned_ids {
delete_caption_embedding(conn, *image_id)?;
}
Ok(orphaned_ids.len())
}
pub fn has_image_vector(conn: &Connection, image_id: i64) -> Result<bool> {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM image_vec WHERE image_id = ?1)",
[image_id],
|row| row.get::<_, i64>(0),
)
.map(|value| value != 0)
.map_err(Into::into)
}
#[allow(dead_code)] #[allow(dead_code)]
fn pack_f32(values: &[f32]) -> Vec<u8> { fn pack_f32(values: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(values.len() * std::mem::size_of::<f32>()); let mut out = Vec::with_capacity(std::mem::size_of_val(values));
for value in values { for value in values {
out.extend_from_slice(&value.to_le_bytes()); out.extend_from_slice(&value.to_le_bytes());
} }
+24 -6
View File
@@ -1,12 +1,12 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Phokus", "productName": "Phokus",
"version": "0.1.0", "version": null,
"identifier": "wtf.jezz.phokus", "identifier": "wtf.jezz.phokus",
"build": { "build": {
"beforeDevCommand": "pnpm dev", "beforeDevCommand": "pnpm dev:vite",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build", "beforeBuildCommand": "pnpm build:vite",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
@@ -22,16 +22,34 @@
} }
], ],
"security": { "security": {
"csp": null, "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost; font-src 'self' data:; object-src 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: http://asset.localhost data: blob:; media-src 'self' asset: http://asset.localhost; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420; font-src 'self' data:; object-src 'none'",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**"] "scope": [
"$APPDATA/thumbnails/**"
]
} }
} }
}, },
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDYyMDVBQzkyOENENjYzOTUKUldTVlk5YU1rcXdGWW9JRklYdHpOVXA1MDNMVEpyell6cVlma0VGS3pYaUVBLzJydy9nNUtJdlUK",
"endpoints": [
"https://github.com/JezzWTF/phokus/releases/latest/download/latest.json"
]
}
},
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": ["nsis"],
"createUpdaterArtifacts": true,
"publisher": "JezzWTF",
"license": "MIT",
"copyright": "Copyright © 2026 JezzWTF",
"shortDescription": "Local-first desktop media library",
"longDescription": "A local-first desktop media library for browsing, filtering, and curating image and video folders, with AI tagging, semantic search, and duplicate detection — all processed on-device.",
"homepage": "https://git.jezz.wtf/jezzwtf/phokus",
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://schema.tauri.app/config/2",
"plugins": {
"updater": {
"endpoints": [
"https://github.com/JezzWTF/phokus/releases/latest/download/latest-cuda.json"
]
}
},
"bundle": {
"windows": {
"nsis": {
"installerHooks": "./windows/cuda-hooks.nsh"
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
; NSIS hooks for the CUDA build variant.
;
; The CUDA exe load-time-imports the CUDA runtime DLLs, so they must sit next
; to phokus.exe ($INSTDIR) where the Windows loader searches. Rather than
; bundling them as Tauri "resources" (which land in $INSTDIR\resources\) and
; then relocating them — a move that proved unreliable in the elevated/silent
; installer (CopyFiles via SHFileOperation, and even Rename, failed) — we embed
; them directly into the installer with NSIS `File` and extract them straight
; to $INSTDIR. `File` is the standard, reliable install primitive.
;
; This hook is !included from its original location, and ${__FILEDIR__} in this
; top-level !define expands at *include* time to this file's own directory,
; src-tauri\windows. cuda-redist is its sibling under src-tauri, so one level
; up. (Caveat that bit us: ${__FILEDIR__} used inline *inside the macro* would
; instead expand at insertion time to Tauri's generated nsis dir — hence the
; !define here.) The 4 DLLs must be present in src-tauri\cuda-redist at build
; time (see RELEASE_PLAN.md).
!define CUDA_REDIST "${__FILEDIR__}\..\cuda-redist"
!macro NSIS_HOOK_POSTINSTALL
DetailPrint "Phokus: installing bundled CUDA runtime libraries..."
SetOutPath "$INSTDIR"
File "${CUDA_REDIST}\cudart64_12.dll"
File "${CUDA_REDIST}\cublas64_12.dll"
File "${CUDA_REDIST}\cublasLt64_12.dll"
File "${CUDA_REDIST}\curand64_10.dll"
!macroend
!macro NSIS_HOOK_POSTUNINSTALL
Delete "$INSTDIR\cudart64_12.dll"
Delete "$INSTDIR\cublas64_12.dll"
Delete "$INSTDIR\cublasLt64_12.dll"
Delete "$INSTDIR\curand64_10.dll"
!macroend
+43 -1
View File
@@ -6,18 +6,45 @@ import { Toolbar } from "./components/Toolbar";
import { Gallery } from "./components/Gallery"; import { Gallery } from "./components/Gallery";
import { Lightbox } from "./components/Lightbox"; import { Lightbox } from "./components/Lightbox";
import { TagCloud } from "./components/TagCloud"; import { TagCloud } from "./components/TagCloud";
import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal";
import { UpdateToast } from "./components/UpdateToast";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications";
export default function App() { export default function App() {
const loadFolders = useGalleryStore((state) => state.loadFolders); const loadFolders = useGalleryStore((state) => state.loadFolders);
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); 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 activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => { useEffect(() => {
void initializeNotifications();
void loadMutedFolderIds();
void loadNotificationsPaused();
void loadAppVersion();
void loadFfmpegStatus();
void loadOnboardingCompleted();
// 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(() => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus();
void loadDuplicateScanCache();
return loadImages(true); return loadImages(true);
}); });
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@@ -38,11 +65,22 @@ export default function App() {
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<Sidebar /> <Sidebar />
<main className="flex-1 flex flex-col min-w-0"> <main className="flex-1 flex flex-col min-w-0">
{activeView === "explore" ? ( {activeView === "timeline" ? (
<>
<Toolbar />
<BackgroundTasks />
<Timeline />
</>
) : activeView === "explore" ? (
<> <>
<BackgroundTasks /> <BackgroundTasks />
<TagCloud /> <TagCloud />
</> </>
) : activeView === "duplicates" ? (
<>
<BackgroundTasks />
<DuplicateFinder />
</>
) : ( ) : (
<> <>
<Toolbar /> <Toolbar />
@@ -54,6 +92,10 @@ export default function App() {
</div> </div>
<Lightbox /> <Lightbox />
<SettingsModal />
<UpdateToast />
<OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />}
</div> </div>
); );
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+133 -42
View File
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store"; import { useGalleryStore, WorkerKey } from "../store";
type WorkerKey = "thumbnail" | "metadata" | "embedding";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = { const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
Thumbnails: "thumbnail", Thumbnails: "thumbnail",
Metadata: "metadata", Metadata: "metadata",
Embeddings: "embedding", Embeddings: "embedding",
Tags: "tagging",
}; };
interface TaskStage { interface TaskStage {
@@ -22,6 +21,8 @@ interface Task {
name: string; name: string;
stages: TaskStage[]; stages: TaskStage[];
hasFailedEmbeddings: boolean; hasFailedEmbeddings: boolean;
hasFailedTagging: boolean;
hasFailedCaptions: boolean;
pendingMediaWork: number; pendingMediaWork: number;
embeddingProcessed: number; embeddingProcessed: number;
embeddingTotal: number; embeddingTotal: number;
@@ -40,26 +41,20 @@ export function BackgroundTasks() {
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [paused, setPaused] = useState<Record<WorkerKey, boolean>>({
thumbnail: false,
metadata: false,
embedding: false,
});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({}); const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
const setWorkerPaused = useGalleryStore((state) => state.setWorkerPaused);
useEffect(() => { useEffect(() => {
invoke<{ thumbnail_paused: boolean; metadata_paused: boolean; embedding_paused: boolean }>( void loadWorkerStates();
"get_worker_states", }, [folders, loadWorkerStates]);
).then((states) => {
setPaused({
thumbnail: states.thumbnail_paused,
metadata: states.metadata_paused,
embedding: states.embedding_paused,
});
});
}, []);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. // Fetch failed embedding filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo( const failedCounts = useMemo(
@@ -83,13 +78,16 @@ export function BackgroundTasks() {
} }
}, [expanded, failedCounts]); }, [expanded, failedCounts]);
const toggleWorker = (worker: WorkerKey) => { const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
const next = !paused[worker]; return workerPaused[folderId]?.[worker] ?? false;
setPaused((prev) => ({ ...prev, [worker]: next })); };
void invoke("set_worker_paused", { worker, paused: next });
const toggleWorker = (folderId: number, worker: WorkerKey) => {
setWorkerPaused(folderId, worker, !isWorkerPaused(folderId, worker));
}; };
const dismissTask = (id: number, snapshot: string) => { const dismissTask = (id: number, snapshot: string) => {
if (id < 0) return; // system tasks (duplicate scan) cannot be dismissed
setDismissed((prev) => ({ ...prev, [id]: snapshot })); setDismissed((prev) => ({ ...prev, [id]: snapshot }));
setExpanded(false); setExpanded(false);
}; };
@@ -105,13 +103,25 @@ export function BackgroundTasks() {
const embeddingPending = jobs?.embedding_pending ?? 0; const embeddingPending = jobs?.embedding_pending ?? 0;
const embeddingReady = jobs?.embedding_ready ?? 0; const embeddingReady = jobs?.embedding_ready ?? 0;
const embeddingFailed = jobs?.embedding_failed ?? 0; const embeddingFailed = jobs?.embedding_failed ?? 0;
const taggingPending = jobs?.tagging_pending ?? 0;
const taggingReady = jobs?.tagging_ready ?? 0;
const taggingFailed = jobs?.tagging_failed ?? 0;
const captionPending = jobs?.caption_pending ?? 0;
const captionReady = jobs?.caption_ready ?? 0;
const captionFailed = jobs?.caption_failed ?? 0;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending; const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
const embeddingProcessed = embeddingReady + embeddingFailed; const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending; const embeddingTotal = embeddingProcessed + embeddingPending;
const taggingProcessed = taggingReady + taggingFailed;
const taggingTotal = taggingProcessed + taggingPending;
const captionProcessed = captionReady + captionFailed;
const captionTotal = captionProcessed + captionPending;
const hasFailedEmbeddings = embeddingFailed > 0; const hasFailedEmbeddings = embeddingFailed > 0;
const hasFailedTagging = taggingFailed > 0;
const hasFailedCaptions = captionFailed > 0;
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings) return null; if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging && !hasFailedCaptions) return null;
const stages: TaskStage[] = []; const stages: TaskStage[] = [];
@@ -153,6 +163,26 @@ export function BackgroundTasks() {
}); });
} }
if (taggingPending > 0) {
const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0;
stages.push({
label: "Tags",
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (captionPending > 0) {
const pct = captionTotal > 0 ? (captionProcessed / captionTotal) * 100 : 0;
stages.push({
label: "Captions",
detail: `${captionProcessed.toLocaleString()} / ${captionTotal.toLocaleString()}`,
progress: pct,
failed: false,
});
}
if (hasFailedEmbeddings && pendingMediaWork === 0) { if (hasFailedEmbeddings && pendingMediaWork === 0) {
stages.push({ stages.push({
label: "Failed", label: "Failed",
@@ -162,13 +192,33 @@ export function BackgroundTasks() {
}); });
} }
const snapshot = `${pendingMediaWork}:${embeddingFailed}`; if (hasFailedTagging && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${taggingFailed.toLocaleString()} tags`,
progress: null,
failed: true,
});
}
if (hasFailedCaptions && pendingMediaWork === 0) {
stages.push({
label: "Failed",
detail: `${captionFailed.toLocaleString()} captions`,
progress: null,
failed: true,
});
}
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}:${captionFailed}`;
return { return {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
stages, stages,
hasFailedEmbeddings, hasFailedEmbeddings,
hasFailedTagging,
hasFailedCaptions,
pendingMediaWork, pendingMediaWork,
embeddingProcessed, embeddingProcessed,
embeddingTotal, embeddingTotal,
@@ -180,17 +230,49 @@ export function BackgroundTasks() {
.filter((t) => dismissed[t.id] !== t.snapshot); .filter((t) => dismissed[t.id] !== t.snapshot);
}, [folders, indexingProgress, mediaJobProgress, dismissed]); }, [folders, indexingProgress, mediaJobProgress, dismissed]);
if (tasks.length === 0) return null; // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
const duplicateScanTask: Task | null = duplicateScanning ? {
id: -1,
name: "Duplicate Scan",
stages: [{
label: duplicateScanProgress?.phase === "checking"
? "Checking"
: duplicateScanProgress?.phase === "confirming"
? "Confirming"
: "Hashing",
detail: duplicateScanProgress
? `${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting…",
progress: duplicateScanProgress && duplicateScanProgress.total > 0
? (duplicateScanProgress.processed / duplicateScanProgress.total) * 100
: null,
failed: false,
}],
hasFailedEmbeddings: false,
hasFailedTagging: false,
hasFailedCaptions: false,
pendingMediaWork: 1,
embeddingProcessed: 0,
embeddingTotal: 0,
currentFile: null,
snapshot: "",
} : null;
const primary = tasks[0]; const allTasks = duplicateScanTask ? [duplicateScanTask, ...tasks] : tasks;
const extraCount = tasks.length - 1;
const hasFailed = tasks.some((t) => t.hasFailedEmbeddings && t.pendingMediaWork === 0); if (allTasks.length === 0) return null;
const primary = allTasks[0];
const extraCount = allTasks.length - 1;
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging || t.hasFailedCaptions) && t.pendingMediaWork === 0);
// Best progress bar value: use embedding progress if available (most informative), // Best progress bar value: use embedding progress if available (most informative),
// otherwise fall back to scanning progress, otherwise indeterminate. // otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate.
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings"); const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
const taggingStage = primary.stages.find((s) => s.label === "Tags");
const scanningStage = primary.stages.find((s) => s.label === "Scanning"); const scanningStage = primary.stages.find((s) => s.label === "Scanning");
const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null; const duplicateStage = primary.id === -1 ? primary.stages[0] : null;
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? duplicateStage?.progress ?? null;
return ( return (
<div className="shrink-0 border-b border-white/[0.06]"> <div className="shrink-0 border-b border-white/[0.06]">
@@ -214,7 +296,7 @@ export function BackgroundTasks() {
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden"> <div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{primary.stages.map((stage) => { {primary.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label]; const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? paused[workerKey] : false; const isPaused = workerKey ? isWorkerPaused(primary.id, workerKey) : false;
return ( return (
<span <span
key={stage.label} key={stage.label}
@@ -234,7 +316,7 @@ export function BackgroundTasks() {
<button <button
className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity" className="ml-0.5 opacity-0 group-hover:opacity-100 hover:text-white transition-opacity"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={(e) => { e.stopPropagation(); toggleWorker(workerKey); }} onClick={(e) => { e.stopPropagation(); toggleWorker(primary.id, workerKey); }}
> >
{isPaused ? ( {isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
@@ -283,8 +365,8 @@ export function BackgroundTasks() {
</button> </button>
)} )}
{/* Expand chevron (only when multiple folders) */} {/* Expand chevron (only when multiple tasks) */}
{tasks.length > 1 && ( {allTasks.length > 1 && (
<svg <svg
className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`} className={`h-3.5 w-3.5 text-gray-600 transition-transform duration-200 shrink-0 ${expanded ? "rotate-180" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" fill="none" viewBox="0 0 24 24" stroke="currentColor"
@@ -293,7 +375,8 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
{/* Dismiss */} {/* Dismiss — hidden for system tasks like duplicate scan */}
{primary.id >= 0 && (
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss" title="Dismiss"
@@ -303,16 +386,19 @@ export function BackgroundTasks() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)}
</div> </div>
{/* Expanded panel — one row per folder */} {/* Expanded panel — one row per folder */}
{expanded && ( {expanded && (
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3"> <div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
{tasks.map((task) => { {allTasks.map((task) => {
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings"); const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
const taskScanningStage = task.stages.find((s) => s.label === "Scanning"); const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null; const taskDuplicateStage = task.id === -1 ? task.stages[0] : null;
const taskHasFailed = task.hasFailedEmbeddings && task.pendingMediaWork === 0; const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? taskDuplicateStage?.progress ?? null;
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging || task.hasFailedCaptions) && task.pendingMediaWork === 0;
return ( return (
<div key={task.id}> <div key={task.id}>
@@ -322,7 +408,7 @@ export function BackgroundTasks() {
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden"> <div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-hidden">
{task.stages.map((stage) => { {task.stages.map((stage) => {
const workerKey = WORKER_FOR_STAGE[stage.label]; const workerKey = WORKER_FOR_STAGE[stage.label];
const isPaused = workerKey ? paused[workerKey] : false; const isPaused = workerKey ? isWorkerPaused(task.id, workerKey) : false;
return ( return (
<span <span
key={stage.label} key={stage.label}
@@ -347,7 +433,7 @@ export function BackgroundTasks() {
<button <button
className="ml-0.5 text-gray-600 hover:text-white transition-colors" className="ml-0.5 text-gray-600 hover:text-white transition-colors"
title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`} title={isPaused ? `Resume ${stage.label}` : `Pause ${stage.label}`}
onClick={() => toggleWorker(workerKey)} onClick={() => toggleWorker(task.id, workerKey)}
> >
{isPaused ? ( {isPaused ? (
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 24 24">
@@ -381,12 +467,16 @@ export function BackgroundTasks() {
{taskHasFailed && ( {taskHasFailed && (
<button <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 hover:bg-amber-500/20 transition-colors shrink-0"
onClick={() => void retryFailedEmbeddings(task.id)} onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
> >
Retry Retry
</button> </button>
)} )}
{task.id >= 0 && (
<button <button
className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0" className="p-1 rounded-md text-gray-600 hover:text-gray-300 hover:bg-white/8 transition-colors shrink-0"
title="Dismiss" title="Dismiss"
@@ -396,6 +486,7 @@ export function BackgroundTasks() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)}
</div> </div>
{task.currentFile && ( {task.currentFile && (
+220
View File
@@ -0,0 +1,220 @@
import { useEffect, useRef, useState } from "react";
import { FolderJobProgress, useGalleryStore } from "../store";
// Dev-only screenshot/demo helper. Injects frozen UI states that are otherwise
// too transient (or unreachable) to capture: the background-worker pipeline,
// which drains in under a second, and the auto-updater flow, which never fires
// locally because there's no remote latest.json to check against.
// Gated by import.meta.env.DEV in App.tsx so the whole thing is tree-shaken out
// of production builds.
//
// Toggle the controls with Ctrl+Shift+D. Injected state persists in the store
// while the panel is hidden, so: open → inject → hide → screenshot.
function emptyProgress(folderId: number): FolderJobProgress {
return {
folder_id: folderId,
thumbnail_pending: 0,
metadata_pending: 0,
embedding_pending: 0,
embedding_ready: 0,
embedding_failed: 0,
caption_pending: 0,
caption_ready: 0,
caption_failed: 0,
tagging_pending: 0,
tagging_ready: 0,
tagging_failed: 0,
};
}
// A believable multi-folder "busy pipeline" — three folders at different stages.
const BUSY_PRESETS: Partial<FolderJobProgress>[] = [
// Embeddings actively running, tagging queued behind it.
{ embedding_ready: 34, embedding_pending: 16, tagging_pending: 50 },
// Just started: thumbnails draining, everything else queued.
{ thumbnail_pending: 11, embedding_pending: 50, tagging_pending: 50 },
// Late stage: embeddings done, tagging running.
{ embedding_ready: 40, tagging_ready: 18, tagging_pending: 22 },
];
const DEMO_UPDATE_VERSION = "0.2.0";
export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders);
const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.ctrlKey && event.shiftKey && event.key.toLowerCase() === "d") {
event.preventDefault();
setOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const stopTimer = () => {
if (downloadTimer.current !== null) {
window.clearInterval(downloadTimer.current);
downloadTimer.current = null;
}
};
// Stop any running download animation when the panel unmounts.
useEffect(() => stopTimer, []);
const injectBusy = () => {
const progress: Record<number, FolderJobProgress> = {};
folders.slice(0, BUSY_PRESETS.length).forEach((folder, index) => {
progress[folder.id] = { ...emptyProgress(folder.id), ...BUSY_PRESETS[index] };
});
useGalleryStore.setState({ mediaJobProgress: progress });
};
const injectSingleEmbedding = () => {
const folder = folders[0];
if (!folder) return;
useGalleryStore.setState({
mediaJobProgress: {
[folder.id]: { ...emptyProgress(folder.id), embedding_ready: 27, embedding_pending: 23 },
},
});
};
const clear = () => useGalleryStore.setState({ mediaJobProgress: {} });
// --- Updater flow ---------------------------------------------------------
// These drive the real UpdateToast + Settings "About" row. The Install button
// stays inert (there's no real pendingUpdate), so this validates the
// presentation only — see DemoPanel's header note for the real e2e path.
const updateAvailable = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "available",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
// Indeterminate pulse, then climb 0 → 100%, then flip to "installing".
const simulateDownload = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "downloading",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
let progress = 0;
window.setTimeout(() => {
downloadTimer.current = window.setInterval(() => {
progress = Math.min(progress + 0.07, 1);
useGalleryStore.setState({ updateProgress: progress });
if (progress >= 1) {
stopTimer();
useGalleryStore.setState({ updateStatus: "installing" });
}
}, 200);
}, 700);
};
const updateInstalling = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "installing",
updateVersion: DEMO_UPDATE_VERSION,
updateProgress: 1,
updateDismissed: false,
});
};
const updateError = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "error",
updateError: "Could not reach the update server (connection timed out).",
updateDismissed: false,
});
};
const updateUpToDate = () => {
stopTimer();
useGalleryStore.setState({ updateStatus: "upToDate", updateVersion: null, updateError: null });
};
const resetUpdate = () => {
stopTimer();
useGalleryStore.setState({
updateStatus: "idle",
updateVersion: null,
updateProgress: null,
updateError: null,
updateDismissed: false,
});
};
if (!open) return null;
const injectBtn =
"rounded-md border border-amber-400/30 bg-amber-500/15 px-2 py-1.5 text-left hover:bg-amber-500/25";
const neutralBtn =
"rounded-md border border-white/10 bg-white/[0.06] px-2 py-1.5 text-left text-gray-300 hover:bg-white/10";
return (
<div className="fixed bottom-4 left-4 z-[100] max-h-[calc(100vh-2rem)] w-56 overflow-y-auto rounded-lg border border-amber-400/40 bg-amber-950/90 p-3 text-xs text-amber-100 shadow-xl backdrop-blur">
<div className="mb-2 flex items-center justify-between">
<span className="font-semibold uppercase tracking-wide">Demo · Ctrl+Shift+D</span>
</div>
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">Pipeline</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Inject a frozen worker-bar state, hide this panel, then screenshot.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={injectBusy}>
Pipeline: busy (3 folders)
</button>
<button className={injectBtn} onClick={injectSingleEmbedding}>
Pipeline: embedding (1 folder)
</button>
<button className={neutralBtn} onClick={clear}>
Clear injected 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">Update flow</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Drives the real toast (bottom-right) + Settings About row. Install is inert here.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={updateAvailable}>
Update available
</button>
<button className={injectBtn} onClick={simulateDownload}>
Simulate download install
</button>
<button className={injectBtn} onClick={updateInstalling}>
Installing
</button>
<button className={injectBtn} onClick={updateError}>
Check failed (error)
</button>
<button className={injectBtn} onClick={updateUpToDate}>
Up to date
</button>
<button className={neutralBtn} onClick={resetUpdate}>
Reset updater state
</button>
</div>
</div>
);
}
+291
View File
@@ -0,0 +1,291 @@
import { useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
function formatBytes(bytes: number): string {
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
const selectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const toggleDuplicateSelected = useGalleryStore((state) => state.toggleDuplicateSelected);
const selectAllDuplicates = useGalleryStore((state) => state.selectAllDuplicates);
const groupSelectedCount = group.images.filter((img) => selectedIds.has(img.id)).length;
const noneSelected = groupSelectedCount === 0;
// "Keep all but the first" — a common quick action
const handleKeepFirst = () => {
const toDelete = group.images.slice(1).map((img) => img.id);
// Clear any selection for this group first, then add the ones to delete
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
selectAllDuplicates(toDelete);
};
return (
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-4">
{/* Group header */}
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{group.images.length} copies
</span>
<span className="text-[11px] text-white/30">{formatBytes(group.file_size)} each</span>
<span className="text-[11px] text-white/20">
{formatBytes(group.file_size * (group.images.length - 1))} wasted
</span>
</div>
<div className="flex items-center gap-2">
{noneSelected ? (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={handleKeepFirst}
>
Keep first
</button>
) : (
<button
className="text-[11px] text-white/35 transition-colors hover:text-white/70"
onClick={() => {
for (const img of group.images) {
if (selectedIds.has(img.id)) toggleDuplicateSelected(img.id);
}
}}
>
Deselect all
</button>
)}
</div>
</div>
{/* Image grid */}
<div className="flex flex-wrap gap-3">
{group.images.map((image) => {
const isSelected = selectedIds.has(image.id);
const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
return (
<button
key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${
isSelected
? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20"
}`}
style={{ width: 140, height: 105 }}
onClick={() => toggleDuplicateSelected(image.id)}
title={image.path}
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
<div className="h-full w-full bg-white/[0.03]" />
)}
{/* Delete overlay */}
{isSelected ? (
<div className="absolute inset-0 flex items-center justify-center bg-red-950/60">
<svg className="h-6 w-6 text-red-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
) : null}
{/* Path tooltip on hover */}
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 to-transparent px-2 pb-1.5 pt-4 opacity-0 transition-opacity group-hover:opacity-100">
<p className="truncate text-[9px] text-white/60">{image.path.split(/[\\/]/).slice(-2).join("/")}</p>
</div>
</button>
);
})}
</div>
</div>
);
}
function formatRelativeTime(unixSecs: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSecs;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
export function DuplicateFinder() {
const duplicateGroups = useGalleryStore((state) => state.duplicateGroups);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const duplicateScanError = useGalleryStore((state) => state.duplicateScanError);
const duplicateScanWarning = useGalleryStore((state) => state.duplicateScanWarning);
const duplicateSelectedIds = useGalleryStore((state) => state.duplicateSelectedIds);
const duplicateLastScanned = useGalleryStore((state) => state.duplicateLastScanned);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const scanDuplicates = useGalleryStore((state) => state.scanDuplicates);
const clearDuplicateSelection = useGalleryStore((state) => state.clearDuplicateSelection);
const selectKeepFirstAllGroups = useGalleryStore((state) => state.selectKeepFirstAllGroups);
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null);
const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
const totalWasted = duplicateGroups.reduce(
(sum, g) => sum + g.file_size * (g.images.length - 1),
0,
);
const totalDuplicateImages = duplicateGroups.reduce((sum, g) => sum + g.images.length - 1, 0);
const handleDelete = async () => {
setDeleting(true);
setDeleteResult(null);
try {
const deleted = await deleteSelectedDuplicates();
setDeleteResult(`Deleted ${deleted} file${deleted === 1 ? "" : "s"}.`);
} catch (e) {
setDeleteResult(String(e));
} finally {
setDeleting(false);
}
};
const progressPercent =
duplicateScanProgress && duplicateScanProgress.total > 0
? Math.round((duplicateScanProgress.processed / duplicateScanProgress.total) * 100)
: 0;
const progressLabel = duplicateScanProgress
? duplicateScanProgress.phase === "checking"
? "Checking file sizes"
: duplicateScanProgress.phase === "hashing"
? "Hashing duplicate candidates"
: "Confirming exact matches"
: null;
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#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>
<h2 className="text-[15px] font-semibold text-white">Duplicate Finder</h2>
<p className="mt-0.5 text-[11px] text-white/30">
{duplicateScanning
? duplicateScanProgress
? `${progressLabel}${duplicateScanProgress.processed.toLocaleString()} / ${duplicateScanProgress.total.toLocaleString()}${duplicateScanProgress.skipped > 0 ? ` · ${duplicateScanProgress.skipped.toLocaleString()} skipped` : ""}`
: "Starting scan…"
: hasResults
? `${duplicateGroups.length} group${duplicateGroups.length === 1 ? "" : "s"} · ${formatBytes(totalWasted)} reclaimable`
: duplicateLastScanned !== null
? "No duplicates found"
: "Scan your library for identical files"}
</p>
{!duplicateScanning && duplicateLastScanned !== null && (
<p className="mt-0.5 text-[10px] text-white/20">
Last scanned {formatRelativeTime(duplicateLastScanned)}
</p>
)}
</div>
<div className="flex items-center gap-2">
<FolderScopeDropdown />
{/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && (
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white"
onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
>
Select all duplicates
</button>
)}
{selectedCount > 0 ? (
<>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40"
onClick={clearDuplicateSelection}
disabled={deleting}
>
Deselect all
</button>
<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}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
</>
) : 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"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning}
>
{duplicateScanning ? "Scanning…" : hasScanned ? "Rescan" : "Scan for duplicates"}
</button>
</div>
</div>
{/* Progress bar */}
{duplicateScanning && duplicateScanProgress ? (
<div className="mt-3 h-px overflow-hidden rounded-full bg-white/[0.07]">
<div
className="h-full rounded-full bg-blue-500/60 transition-[width] duration-200"
style={{ width: `${progressPercent}%` }}
/>
</div>
) : null}
{duplicateScanError ? (
<p className="mt-2 text-[11px] text-red-400/80">{duplicateScanError}</p>
) : null}
{duplicateScanWarning ? (
<p className="mt-2 text-[11px] text-amber-300/70">{duplicateScanWarning}</p>
) : null}
{deleteResult ? (
<p className="mt-2 text-[11px] text-white/40">{deleteResult}</p>
) : null}
</div>
{/* Body */}
{duplicateScanning && !hasResults ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/15 border-t-white/50" />
<span className="text-sm">{progressLabel ? `${progressLabel}` : "Preparing scan…"}</span>
</div>
) : !hasScanned ? (
<div className="flex flex-1 items-center justify-center px-8">
<div className="max-w-sm text-center">
<svg className="mx-auto mb-4 h-10 w-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p className="text-sm text-white/30">
Finds files with identical content regardless of filename or location.
Click <strong className="text-white/50">Scan for duplicates</strong> to begin.
</p>
<p className="mt-2 text-xs text-white/20">
Large libraries may take a minute files are hashed from disk.
</p>
</div>
</div>
) : duplicateGroups.length === 0 ? (
<div className="flex flex-1 items-center justify-center">
<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>
</div>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
/**
* In-view folder scope picker for feature views (Timeline / Explore /
* Duplicates). Changes the scope via setViewFolderScope, which keeps the
* current view active — unlike sidebar folder clicks, which jump to Gallery.
*/
export function FolderScopeDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const setViewFolderScope = useGalleryStore((state) => state.setViewFolderScope);
useEffect(() => {
const close = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const currentLabel =
selectedFolderId === null
? "All Media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "All Media";
const select = (folderId: number | null) => {
setViewFolderScope(folderId);
setOpen(false);
};
return (
<div ref={ref} className="relative">
<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 ${
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"
}`}
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" />
</svg>
<span className="truncate">{currentLabel}</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 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">
<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"
}`}
onClick={() => select(null)}
>
All Media
{selectedFolderId === null ? (
<svg className="h-3.5 w-3.5 shrink-0 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
{folders.map((folder) => {
const active = selectedFolderId === folder.id;
return (
<button
key={folder.id}
className={`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"
}`}
onClick={() => select(folder.id)}
>
<span className="min-w-0 truncate">{folder.name}</span>
<span className="flex shrink-0 items-center gap-2">
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()}</span>
{active ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</span>
</button>
);
})}
</div>
) : null}
</div>
);
}
+59 -25
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useRef, useCallback, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
const GAP = 6; const GAP = 6;
@@ -16,7 +16,7 @@ function formatDuration(durationMs: number | null): string | null {
return `${minutes}:${seconds.toString().padStart(2, "0")}`; return `${minutes}:${seconds.toString().padStart(2, "0")}`;
} }
function ContextMenu({ export function ContextMenu({
x, x,
y, y,
image, image,
@@ -30,6 +30,7 @@ function ContextMenu({
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
return ( return (
@@ -59,7 +60,7 @@ function ContextMenu({
}`} }`}
onClick={async () => { onClick={async () => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
await loadSimilarImages(image.id); await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
onClose(); onClose();
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
@@ -103,7 +104,7 @@ function ContextMenu({
); );
} }
function ImageTile({ export function ImageTile({
image, image,
onClick, onClick,
onContextMenu, onContextMenu,
@@ -115,6 +116,7 @@ function ImageTile({
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path const src = image.thumbnail_path
@@ -181,6 +183,15 @@ function ImageTile({
</svg> </svg>
</div> </div>
)} )}
{image.rating > 0 && (
<div className="flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300 backdrop-blur-sm">
{Array.from({ length: image.rating }, (_, index) => (
<svg key={index} className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
)}
{image.media_kind === "video" && image.duration_ms && ( {image.media_kind === "video" && image.duration_ms && (
<div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm"> <div className="rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80 backdrop-blur-sm">
{formatDuration(image.duration_ms)} {formatDuration(image.duration_ms)}
@@ -230,7 +241,7 @@ function ImageTile({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(image.id); void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
@@ -250,7 +261,11 @@ export function Gallery() {
const loadingImages = useGalleryStore((state) => state.loadingImages); const loadingImages = useGalleryStore((state) => state.loadingImages);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search);
const searchMode = useGalleryStore((state) => state.searchMode); const collectionTitle = useGalleryStore((state) => state.collectionTitle);
const imageLoadError = useGalleryStore((state) => state.imageLoadError);
const galleryScrollResetKey = useGalleryStore((state) => state.galleryScrollResetKey);
const isSimilarResults = collectionTitle === "Similar Images";
const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
@@ -258,6 +273,7 @@ export function Gallery() {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const element = parentRef.current; const element = parentRef.current;
if (!element) return; if (!element) return;
if (element.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600; const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages();
@@ -271,6 +287,10 @@ export function Gallery() {
return () => element.removeEventListener("scroll", handleScroll); return () => element.removeEventListener("scroll", handleScroll);
}, [handleScroll]); }, [handleScroll]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
useEffect(() => { useEffect(() => {
const close = (event: PointerEvent) => { const close = (event: PointerEvent) => {
if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return; if ((event.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
@@ -287,51 +307,64 @@ export function Gallery() {
}; };
}, []); }, []);
if (images.length === 0 && loadingImages) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8"> <div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
{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"> <div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8 min-w-72">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> <div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium"> <p className="mt-4 text-sm text-white/40 font-medium">
{searchMode === "semantic" && search.trim().length > 0 {isSimilarResults
? `Searching for matches to "${search}"` ? "Finding similar images"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? `Searching for matches to "${parsedSearch.query}"`
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? `Searching tags for "${parsedSearch.query}"`
: "Loading media"} : "Loading media"}
</p> </p>
<p className="text-xs text-white/20 mt-1"> <p className="text-xs text-white/20 mt-1">
{searchMode === "semantic" && search.trim().length > 0 {isSimilarResults
? "Comparing visual embeddings"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Semantic search can take a little longer than filename search" ? "Semantic search can take a little longer than filename search"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Matching against AI and user tags"
: "Fetching results"} : "Fetching results"}
</p> </p>
</div> </div>
</div> </div>
); ) : images.length === 0 && !loadingImages ? (
} <div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8 absolute inset-0">
if (images.length === 0 && !loadingImages) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 text-center px-8">
<div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8"> <div className="rounded-2xl border border-white/[0.06] bg-white/[0.02] p-8">
<svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-12 w-12 mx-auto text-white/10 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={0.75}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> 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> </svg>
<p className="text-sm text-white/30 font-medium"> <p className="text-sm text-white/30 font-medium">
{searchMode === "semantic" && search.trim().length > 0 {imageLoadError
? "Could not load results"
: isSimilarResults
? "No similar images found"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "No semantic matches found" ? "No semantic matches found"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "No tag matches found"
: "No media found"} : "No media found"}
</p> </p>
<p className="text-xs text-white/15 mt-1"> <p className="text-xs text-white/15 mt-1">
{searchMode === "semantic" && search.trim().length > 0 {imageLoadError
? imageLoadError
: isSimilarResults
? "This item may be visually isolated, or more embeddings may need to finish processing"
: parsedSearch.mode === "semantic" && parsedSearch.query.length > 0
? "Try a broader phrase, or wait for more embeddings to finish processing" ? "Try a broader phrase, or wait for more embeddings to finish processing"
: parsedSearch.mode === "tag" && parsedSearch.query.length > 0
? "Try a shorter tag, or wait for more tagging jobs to finish"
: "Try adjusting your filters or add a new folder"} : "Try adjusting your filters or add a new folder"}
</p> </p>
</div> </div>
</div> </div>
); ) : (
}
return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]">
<div <div
className="grid content-start" className="grid content-start"
style={{ style={{
@@ -352,8 +385,9 @@ export function Gallery() {
/> />
))} ))}
</div> </div>
)}
{loadingImages ? ( {images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" /> <div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div> </div>
+495 -32
View File
@@ -1,7 +1,8 @@
import { useEffect, useCallback, useRef, useState } from "react"; import { useEffect, useCallback, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store"; import { useGalleryStore, ImageTag, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
@@ -45,18 +46,140 @@ function embeddingLabel(status: string, model: string | null): string {
return "Queued"; return "Queued";
} }
function ratingPill(rating: AiRating): { label: string; className: string } {
switch (rating) {
case "general":
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" };
case "sensitive":
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" };
case "questionable":
return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" };
case "explicit":
return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" };
}
}
interface DragRect {
startX: number;
startY: number;
endX: number;
endY: number;
}
/** Compute a CSS-pixel rect (relative to the viewport container) from a DragRect. */
function normaliseRect(r: DragRect): { left: number; top: number; width: number; height: number } {
return {
left: Math.min(r.startX, r.endX),
top: Math.min(r.startY, r.endY),
width: Math.abs(r.endX - r.startX),
height: Math.abs(r.endY - r.startY),
};
}
/** Convert a CSS-pixel drag rect (relative to viewport container) to normalised 01 crop coords
* relative to the actual rendered <img> element bounds. */
function rectToNormalisedCrop(
rect: DragRect,
imgEl: HTMLImageElement,
): { x: number; y: number; w: number; h: number } | null {
const imgBounds = imgEl.getBoundingClientRect();
if (imgBounds.width === 0 || imgBounds.height === 0) return null;
// rect coords are already in viewport space (client coords)
const rawX = Math.min(rect.startX, rect.endX);
const rawY = Math.min(rect.startY, rect.endY);
const rawW = Math.abs(rect.endX - rect.startX);
const rawH = Math.abs(rect.endY - rect.startY);
// Clamp to image bounds
const clampedX = Math.max(rawX, imgBounds.left);
const clampedY = Math.max(rawY, imgBounds.top);
const clampedRight = Math.min(rawX + rawW, imgBounds.right);
const clampedBottom = Math.min(rawY + rawH, imgBounds.bottom);
const croppedW = clampedRight - clampedX;
const croppedH = clampedBottom - clampedY;
if (croppedW <= 0 || croppedH <= 0) return null;
// Normalize by the CSS transform scale — getBoundingClientRect already returns
// the scaled (on-screen) size, so we normalize directly against that.
return {
x: (clampedX - imgBounds.left) / imgBounds.width,
y: (clampedY - imgBounds.top) / imgBounds.height,
w: croppedW / imgBounds.width,
h: croppedH / imgBounds.height,
};
}
/** Minimum selection size as a fraction of the viewport container dimension. */
const MIN_SELECTION_FRACTION = 0.02;
const MIN_ZOOM = 0.5;
const MAX_ZOOM = 4;
interface ViewTransform {
zoom: number;
panX: number;
panY: number;
}
const IDENTITY_VIEW: ViewTransform = { zoom: 1, panX: 0, panY: 0 };
/** Re-anchor the pan so the point at (anchorX, anchorY) — measured from the
* viewport centre — stays under the cursor across a zoom change. */
function zoomViewAt(view: ViewTransform, newZoom: number, anchorX: number, anchorY: number): ViewTransform {
if (newZoom <= 1) return { ...IDENTITY_VIEW, zoom: newZoom };
const ratio = newZoom / view.zoom;
return {
zoom: newZoom,
panX: anchorX - (anchorX - view.panX) * ratio,
panY: anchorY - (anchorY - view.panY) * ratio,
};
}
export function Lightbox() { export function Lightbox() {
const selectedImage = useGalleryStore((state) => state.selectedImage); const selectedImage = useGalleryStore((state) => state.selectedImage);
const closeImage = useGalleryStore((state) => state.closeImage); const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const [zoom, setZoom] = useState(1); 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);
// Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image.
const currentImageIdRef = useRef<number | null>(null);
currentImageIdRef.current = selectedImage?.id ?? null;
const [view, setView] = useState<ViewTransform>(IDENTITY_VIEW);
const zoom = view.zoom;
const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 });
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false);
// Region selection state
const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null);
const [regionSearching, setRegionSearching] = useState(false);
const imageViewportRef = useRef<HTMLDivElement>(null); const imageViewportRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1; const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
const canFindSimilar = selectedImage?.embedding_status === "ready"; const canFindSimilar = selectedImage?.embedding_status === "ready";
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]); if (currentIndex > 0) openImage(images[currentIndex - 1]);
@@ -66,40 +189,188 @@ export function Lightbox() {
if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]); if (currentIndex >= 0 && currentIndex < images.length - 1) openImage(images[currentIndex + 1]);
}, [currentIndex, images, openImage]); }, [currentIndex, images, openImage]);
const exitRegionMode = useCallback(() => {
setRegionSelectMode(false);
setIsDragging(false);
setDragRect(null);
}, []);
// Keep the pan within bounds: when the scaled image overflows the viewport
// the image edge may not be dragged past the viewport edge; when it fits,
// the pan snaps back to centre on that axis.
const clampPan = useCallback((v: ViewTransform): ViewTransform => {
const img = imgRef.current;
const vp = imageViewportRef.current;
if (!img || !vp) return v;
const maxX = Math.max(0, (img.offsetWidth * v.zoom - vp.clientWidth) / 2);
const maxY = Math.max(0, (img.offsetHeight * v.zoom - vp.clientHeight) / 2);
return {
...v,
panX: Math.min(maxX, Math.max(-maxX, v.panX)),
panY: Math.min(maxY, Math.max(-maxY, v.panY)),
};
}, []);
useEffect(() => { useEffect(() => {
setZoom(1); setView(IDENTITY_VIEW);
}, [selectedImage?.id]); setImageTags([]);
setTagInput("");
setTagsExpanded(false);
setTaggingQueued(false);
exitRegionMode();
setRegionSearching(false);
}, [selectedImage?.id, exitRegionMode]);
useEffect(() => {
if (!selectedImage) return;
// Capture the ID so a stale response for image A cannot overwrite B's tags
// when the user navigates before the request resolves.
let cancelled = false;
void getImageTags(selectedImage.id)
.then((tags) => { if (!cancelled) setImageTags(tags); })
.catch(() => { if (!cancelled) setImageTags([]); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// Reset the queued state once the worker finishes so the button is usable again
useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
}, [selectedImage?.ai_tagged_at]);
useEffect(() => { useEffect(() => {
const viewport = imageViewportRef.current; const viewport = imageViewportRef.current;
if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return; if (!viewport || !selectedImage || selectedImage.media_kind !== "image") return;
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
if (regionSelectMode) return; // don't zoom during selection
if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return; if (!event.ctrlKey && Math.abs(event.deltaY) < Math.abs(event.deltaX)) return;
event.preventDefault(); event.preventDefault();
setZoom((value) => { const bounds = viewport.getBoundingClientRect();
const anchorX = event.clientX - (bounds.left + bounds.width / 2);
const anchorY = event.clientY - (bounds.top + bounds.height / 2);
setView((v) => {
const delta = event.deltaY < 0 ? 0.15 : -0.15; const delta = event.deltaY < 0 ? 0.15 : -0.15;
return Math.min(4, Math.max(0.5, value + delta)); const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, v.zoom + delta));
return clampPan(zoomViewAt(v, next, anchorX, anchorY));
}); });
}; };
viewport.addEventListener("wheel", handleWheel, { passive: false }); viewport.addEventListener("wheel", handleWheel, { passive: false });
return () => viewport.removeEventListener("wheel", handleWheel); return () => viewport.removeEventListener("wheel", handleWheel);
}, [selectedImage]); }, [selectedImage, regionSelectMode, clampPan]);
useEffect(() => { useEffect(() => {
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
if (!selectedImage) return; if (!selectedImage) return;
if (event.key === "Escape") closeImage(); if (event.key === "Escape") {
if (event.key === "ArrowLeft") goPrev(); if (regionSelectMode) {
if (event.key === "ArrowRight") goNext(); exitRegionMode();
if (event.key === "+" || event.key === "=") setZoom((value) => Math.min(3, value + 0.25)); } else {
if (event.key === "-") setZoom((value) => Math.max(0.75, value - 0.25)); closeImage();
}
}
if (regionSelectMode) return; // block nav keys during selection
// Shift+arrows are reserved for video seeking (handled by VideoPlayer)
if (event.key === "ArrowLeft" && !event.shiftKey) goPrev();
if (event.key === "ArrowRight" && !event.shiftKey) goNext();
if (event.key === "+" || event.key === "=")
setView((v) => clampPan(zoomViewAt(v, Math.min(3, v.zoom + 0.25), 0, 0)));
if (event.key === "-")
setView((v) => clampPan(zoomViewAt(v, Math.max(0.75, v.zoom - 0.25), 0, 0)));
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);
}, [selectedImage, closeImage, goPrev, goNext]); }, [selectedImage, closeImage, goPrev, goNext, regionSelectMode, exitRegionMode, clampPan]);
// ── Region selection / pan pointer handlers ─────────────────────────────────
const handleRegionPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!regionSelectMode) {
// Drag-to-pan when the image is zoomed in
if (zoom > 1 && event.button === 0 && selectedImage?.media_kind === "image") {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setIsPanning(true);
}
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
setDragRect({
startX: event.clientX,
startY: event.clientY,
endX: event.clientX,
endY: event.clientY,
});
},
[regionSelectMode, zoom, selectedImage?.media_kind],
);
const handleRegionPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
const dx = event.clientX - lastPanPointRef.current.x;
const dy = event.clientY - lastPanPointRef.current.y;
lastPanPointRef.current = { x: event.clientX, y: event.clientY };
setView((v) => clampPan({ ...v, panX: v.panX + dx, panY: v.panY + dy }));
return;
}
if (!isDragging) return;
setDragRect((prev) =>
prev ? { ...prev, endX: event.clientX, endY: event.clientY } : null,
);
},
[isDragging, isPanning, clampPan],
);
const handleRegionPointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (isPanning) {
event.currentTarget.releasePointerCapture(event.pointerId);
setIsPanning(false);
return;
}
if (!isDragging || !dragRect || !selectedImage || !imgRef.current) {
setIsDragging(false);
return;
}
event.currentTarget.releasePointerCapture(event.pointerId);
const finalRect: DragRect = { ...dragRect, endX: event.clientX, endY: event.clientY };
const crop = rectToNormalisedCrop(finalRect, imgRef.current);
setIsDragging(false);
setDragRect(null);
// Ignore tiny accidental clicks
const containerBounds = imageViewportRef.current?.getBoundingClientRect();
const containerSize = containerBounds
? Math.min(containerBounds.width, containerBounds.height)
: 500;
const selW = Math.abs(finalRect.endX - finalRect.startX);
const selH = Math.abs(finalRect.endY - finalRect.startY);
if (!crop || selW < containerSize * MIN_SELECTION_FRACTION || selH < containerSize * MIN_SELECTION_FRACTION) {
exitRegionMode();
return;
}
exitRegionMode();
setRegionSearching(true);
const folderId =
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
);
// Build the CSS rect for the selection overlay (viewport-relative)
const selectionOverlay =
isDragging && dragRect ? normaliseRect(dragRect) : null;
return ( return (
<AnimatePresence> <AnimatePresence>
@@ -111,11 +382,11 @@ export function Lightbox() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ duration: 0.15 }} transition={{ duration: 0.15 }}
onClick={closeImage} onClick={regionSelectMode ? undefined : closeImage}
> >
<button <button
className="absolute left-4 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 left-4 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"
disabled={currentIndex <= 0} disabled={currentIndex <= 0 || regionSelectMode}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
goPrev(); goPrev();
@@ -130,61 +401,102 @@ export function Lightbox() {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div <div
ref={imageViewportRef} ref={imageViewportRef}
className="group relative flex flex-1 items-center justify-center overflow-auto p-10" className={`group relative flex flex-1 items-center justify-center overflow-hidden p-10 ${
regionSelectMode
? "cursor-crosshair select-none"
: isPanning
? "cursor-grabbing select-none"
: zoom > 1 && selectedImage.media_kind === "image"
? "cursor-grab"
: ""
}`}
onPointerDown={handleRegionPointerDown}
onPointerMove={handleRegionPointerMove}
onPointerUp={handleRegionPointerUp}
> >
{/* Region selection mode hint */}
{regionSelectMode && (
<div className="pointer-events-none absolute inset-x-0 top-4 z-20 flex justify-center">
<div className="flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-xs text-gray-300 backdrop-blur">
<svg className="h-3.5 w-3.5 text-violet-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
Draw a region to search <kbd className="ml-1 rounded border border-white/20 bg-white/10 px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd> to cancel
</div>
</div>
)}
{/* Selection rectangle overlay */}
{selectionOverlay && selectionOverlay.width > 4 && selectionOverlay.height > 4 && (
<div
className="pointer-events-none fixed z-30 rounded border-2 border-violet-400 bg-violet-400/15 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]"
style={{
left: selectionOverlay.left,
top: selectionOverlay.top,
width: selectionOverlay.width,
height: selectionOverlay.height,
}}
/>
)}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={selectedImage.id} key={selectedImage.id}
className="flex items-center justify-center" className={
selectedImage.media_kind === "video"
? "absolute inset-0"
: "flex items-center justify-center"
}
initial={{ opacity: 0.3, scale: 0.985 }} initial={{ opacity: 0.3, scale: 0.985 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0.3, scale: 0.985 }} exit={{ opacity: 0.3, scale: 0.985 }}
transition={{ duration: 0.12 }} transition={{ duration: 0.12 }}
> >
{selectedImage.media_kind === "video" ? ( {selectedImage.media_kind === "video" ? (
<video <VideoPlayer src={convertFileSrc(selectedImage.path)} />
src={convertFileSrc(selectedImage.path)}
controls
className="max-h-full max-w-full rounded-2xl shadow-2xl"
style={{ maxHeight: "calc(100vh - 10rem)" }}
/>
) : ( ) : (
<> <>
<img <img
ref={imgRef}
src={convertFileSrc(selectedImage.path)} src={convertFileSrc(selectedImage.path)}
alt={selectedImage.filename} alt={selectedImage.filename}
className="max-w-full rounded-2xl shadow-2xl" className="max-w-full rounded-2xl shadow-2xl"
draggable={false}
style={{ style={{
maxHeight: "calc(100vh - 10rem)", maxHeight: "calc(100vh - 10rem)",
transform: `scale(${zoom})`, transform: `translate(${view.panX}px, ${view.panY}px) scale(${zoom})`,
transformOrigin: "center center", transformOrigin: "center center",
// Slightly dim the image while in region select mode
...(regionSelectMode ? { opacity: 0.85 } : {}),
}} }}
/> />
{!regionSelectMode && (
<div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100"> <div className="pointer-events-none absolute right-6 top-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur"> <div className="pointer-events-auto flex items-center gap-1 rounded-full border border-white/10 bg-black/55 px-2 py-1 backdrop-blur">
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.max(0.5, value - 0.25))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.max(MIN_ZOOM, v.zoom - 0.25), 0, 0)))}
> >
- -
</button> </button>
<span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span> <span className="min-w-14 text-center text-xs text-gray-300">{Math.round(zoom * 100)}%</span>
<button <button
className="px-2 text-sm text-gray-300 hover:text-white" className="px-2 text-sm text-gray-300 hover:text-white"
onClick={() => setZoom((value) => Math.min(4, value + 0.25))} onClick={() => setView((v) => clampPan(zoomViewAt(v, Math.min(MAX_ZOOM, v.zoom + 0.25), 0, 0)))}
> >
+ +
</button> </button>
</div> </div>
</div> </div>
)}
</> </>
)} )}
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5"> <div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
<div className="mb-5 flex items-center justify-between"> <div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p> <p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
<p className="text-xs text-gray-500">Details</p> <p className="text-xs text-gray-500">Details</p>
@@ -207,7 +519,7 @@ export function Lightbox() {
}`} }`}
onClick={() => { onClick={() => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id); void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
@@ -221,7 +533,54 @@ export function Lightbox() {
</button> </button>
</div> </div>
<div className="space-y-4 text-sm"> {/* Search region button row */}
{canSearchRegion && (
<div className="shrink-0 px-5 pb-3">
<button
className={`w-full rounded-lg border px-3 py-2 text-xs transition-colors ${
regionSelectMode
? "border-violet-400/40 bg-violet-500/15 text-violet-300 hover:bg-violet-500/20"
: regionSearching
? "border-white/5 bg-white/[0.03] text-gray-500 cursor-not-allowed"
: "border-white/10 bg-white/5 text-gray-300 hover:bg-white/10 hover:text-white"
}`}
onClick={() => {
if (regionSearching) return;
setRegionSelectMode((prev) => !prev);
setDragRect(null);
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">
<svg className="h-3 w-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Searching region
</span>
) : regionSelectMode ? (
<span className="flex items-center justify-center gap-1.5">
<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>
Cancel selection
</span>
) : (
<span className="flex items-center justify-center gap-1.5">
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
Search within image
</span>
)}
</button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -316,13 +675,117 @@ export function Lightbox() {
) : null} ) : null}
</div> </div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Tags</p>
<div className="flex items-center gap-1.5">
{selectedImage.ai_rating ? (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${ratingPill(selectedImage.ai_rating).className}`}>
{ratingPill(selectedImage.ai_rating).label}
</span>
) : null}
{selectedImage.media_kind === "image" ? (
<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);
}}
>
{taggingQueued ? "Queued" : "AI tags"}
</button>
) : null}
</div>
</div>
{imageTags.length > 0 ? (
<>
<div className="flex flex-wrap gap-1.5">
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
<span
key={t.id}
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}
<button
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
onClick={() => {
void removeTag(t.id).then(() =>
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>
</span>
))}
</div>
{imageTags.length > 8 && (
<button
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
onClick={() => setTagsExpanded((v) => !v)}
>
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`}
</button>
)}
</>
) : (
<p className="text-xs text-gray-600">No tags yet</p>
)}
<form
className="mt-2 flex gap-1.5"
onSubmit={(e) => {
e.preventDefault();
const raw = tagInput.trim();
if (!raw || tagAdding) return;
setTagAdding(true);
const taggedImageId = selectedImage.id;
void addUserTag(taggedImageId, raw)
.then((newTag) => {
// Discard if the user navigated away before the request resolved.
if (currentImageIdRef.current !== taggedImageId) return;
setImageTags((prev) => [...prev, newTag]);
setTagInput("");
})
.catch(() => undefined)
.finally(() => setTagAdding(false));
}}
>
<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="Add tag…"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
disabled={tagAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
disabled={tagAdding || !tagInput.trim()}
>
Add
</button>
</form>
</div>
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p> <p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p>
<p className="break-all text-xs text-gray-400">{selectedImage.path}</p> <p className="break-all text-xs text-gray-400">{selectedImage.path}</p>
</div> </div>
</div> </div>
<div className="mt-auto pt-4 text-center text-xs text-gray-600"> <div className="shrink-0 mt-auto px-5 pb-4 pt-2 text-center text-xs text-gray-600">
{currentIndex + 1} / {images.length} {currentIndex + 1} / {images.length}
</div> </div>
</div> </div>
@@ -331,7 +794,7 @@ export function Lightbox() {
<button <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-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"
disabled={currentIndex >= images.length - 1} disabled={currentIndex >= images.length - 1 || regionSelectMode}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
goNext(); goNext();
+39
View File
@@ -0,0 +1,39 @@
// Phokus brand mark — a camera iris, rendered inline so it inherits `currentColor`
// and scales to any size. Drive color + dimensions from the className, e.g.
// <PhokusMark className="h-4 w-4 text-gray-300" />
// The full app-icon tile (filled iris on a dark rounded square) lives in
// branding/phokus-aperture.svg and feeds `pnpm tauri icon`.
//
// Pass dotClassName (e.g. "fill-amber-400") to light up the central focal point —
// used in the titlebar as the "update available" indicator.
const BLADE = "M0,-4.18 A10,10 0 0 1 6.43,-7.66";
export function PhokusMark({
className,
dotClassName,
}: {
className?: string;
dotClassName?: string;
}) {
return (
<svg viewBox="0 0 24 24" fill="none" className={className}>
<g
transform="translate(12 12)"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle r="10" />
<path d="M0,-4.18 A4.73,4.73 0 0 0 3.62,-2.09 A4.73,4.73 0 0 0 3.62,2.09 A4.73,4.73 0 0 0 0,4.18 A4.73,4.73 0 0 0 -3.62,2.09 A4.73,4.73 0 0 0 -3.62,-2.09 A4.73,4.73 0 0 0 0,-4.18 Z" />
<path d={BLADE} />
<path d={BLADE} transform="rotate(60)" />
<path d={BLADE} transform="rotate(120)" />
<path d={BLADE} transform="rotate(180)" />
<path d={BLADE} transform="rotate(240)" />
<path d={BLADE} transform="rotate(300)" />
</g>
{dotClassName ? <circle cx="12" cy="12" r="2.6" stroke="none" className={dotClassName} /> : null}
</svg>
);
}
+818
View File
@@ -0,0 +1,818 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome";
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" },
];
function formatBytesShort(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className =
tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300"
: tone === "busy"
? "border-sky-400/25 bg-sky-500/10 text-sky-300"
: "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>;
}
function SettingsGroup({ title, description, children }: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<section>
<h4 className="text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">{title}</h4>
{description ? <p className="mt-1 text-xs leading-relaxed text-gray-600">{description}</p> : null}
<div className="mt-1 divide-y divide-white/[0.05]">{children}</div>
</section>
);
}
function SettingsItem({ label, description, children, vertical = false }: {
label: React.ReactNode;
description?: React.ReactNode;
children?: React.ReactNode;
vertical?: boolean;
}) {
if (vertical) {
return (
<div className="py-4">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 text-xs leading-relaxed text-gray-500">{description}</div> : null}
{children ? <div className="mt-3">{children}</div> : null}
</div>
);
}
return (
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{label}</p>
{description ? <div className="mt-1 max-w-xl text-xs leading-relaxed text-gray-500">{description}</div> : null}
</div>
<div className="shrink-0">{children}</div>
</div>
);
}
function StatPair({ label, value, accent = false }: { label: string; value: string; accent?: boolean }) {
return (
<span className="inline-flex items-baseline gap-2">
<span className="text-[10px] uppercase tracking-[0.14em] text-gray-600">{label}</span>
<span className={`text-sm font-semibold tabular-nums ${accent ? "text-emerald-300" : "text-white"}`}>{value}</span>
</span>
);
}
function ScopeButton({ scope, current, onSelect, children }: {
scope: TaggingQueueScope;
current: TaggingQueueScope;
onSelect: (scope: TaggingQueueScope) => void;
children: React.ReactNode;
}) {
const active = scope === 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"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`}
onClick={() => onSelect(scope)}
>
{children}
</button>
);
}
function TaggerAccelerationButton({ acceleration, current, onSelect, children }: {
acceleration: TaggerAcceleration;
current: TaggerAcceleration;
onSelect: (acceleration: TaggerAcceleration) => void;
children: React.ReactNode;
}) {
const active = acceleration === 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"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200"
}`}
onClick={() => onSelect(acceleration)}
>
{children}
</button>
);
}
export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
const [taggerThresholdError, setTaggerThresholdError] = useState<string | null>(null);
const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState<string | null>(null);
const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false);
const [taggerBatchSizeError, setTaggerBatchSizeError] = useState<string | null>(null);
const [openingDataFolder, setOpeningDataFolder] = useState(false);
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const folders = useGalleryStore((state) => state.folders);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope);
const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds);
const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope);
const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope);
const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder);
const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds);
const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
const taggerModelError = useGalleryStore((state) => state.taggerModelError);
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
const taggerBatchSize = useGalleryStore((state) => state.taggerBatchSize);
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize);
const setTaggerBatchSize = useGalleryStore((state) => state.setTaggerBatchSize);
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const openOnboarding = useGalleryStore((state) => state.openOnboarding);
useEffect(() => {
if (!settingsOpen) return;
void loadTaggerModelStatus();
void loadTaggerAcceleration();
void loadTaggerThreshold();
void loadTaggerBatchSize();
void loadTaggingQueueScope();
void loadTaggingQueueFolderIds();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setSettingsOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => {
if (!settingsOpen || activeSection !== "general") return;
setVacuumResult(null);
setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {});
void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {});
}, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]);
// Clean up error timers on unmount
useEffect(() => {
return () => {
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
};
}, []);
const selectedFolders = useMemo(
() => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)),
[folders, taggingQueueFolderIds],
);
if (!settingsOpen) return null;
const taggerReady = taggerModelStatus?.ready ?? false;
const queueScopeLabel =
taggingQueueScope === "all"
? "all media"
: selectedFolders.length > 0
? `${selectedFolders.length} selected folder${selectedFolders.length === 1 ? "" : "s"}`
: "no folders selected";
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
const batchSizeDisplay = taggerBatchSizeDraft ?? String(taggerBatchSize);
const taggerBytesKnown =
taggerModelProgress?.downloaded_bytes != null &&
taggerModelProgress.total_bytes != null &&
taggerModelProgress.total_bytes > 0;
const taggerDownloadLabel = taggerBytesKnown
? `Downloading ${formatBytesShort(taggerModelProgress!.downloaded_bytes!)} / ${formatBytesShort(taggerModelProgress!.total_bytes!)}`
: taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing
? "Preparing WD Tagger..."
: taggerReady
? "Installed"
: "Install model";
const taggerDownloadPercent = taggerBytesKnown
? Math.round((taggerModelProgress!.downloaded_bytes! / taggerModelProgress!.total_bytes!) * 100)
: taggerModelProgress
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
: 0;
const runQueueAction = (action: "queue" | "clear") => {
const selectedIds = taggingQueueFolderIds;
const perform =
taggingQueueScope === "all"
? action === "queue"
? queueTaggingJobs(null)
: clearTaggingJobs(null)
: selectedIds.length > 0
? action === "queue"
? queueTaggingJobsForFolders(selectedIds)
: clearTaggingJobsForFolders(selectedIds)
: Promise.resolve(0);
if (action === "queue") {
setTaggerQueueing(true);
} else {
setTaggerClearing(true);
}
setTaggerQueueStatus(null);
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before running tagging jobs.");
return;
}
setTaggerQueueStatus(
count === 0
? action === "queue"
? "No missing tags found for the current target."
: "No queued tagging jobs to clear for the current target."
: action === "queue"
? `Queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for tagging.`
: `Cleared ${count.toLocaleString()} queued tagging job${count === 1 ? "" : "s"}.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
if (action === "queue") {
setTaggerQueueing(false);
} else {
setTaggerClearing(false);
}
});
};
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"
onClick={(event) => event.stopPropagation()}
>
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
<div className="border-b border-white/[0.07] px-5 py-5">
<p className="text-base font-semibold text-white">Settings</p>
<p className="mt-1 text-xs text-gray-600">Operational controls for AI workflows</p>
</div>
<div className="flex-1 space-y-1 overflow-y-auto p-2">
{SECTIONS.map((section) => (
<button
key={section.id}
className={`w-full rounded-md px-3 py-2.5 text-left transition-colors ${
activeSection === section.id ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/[0.055] hover:text-gray-200"
}`}
onClick={() => setActiveSection(section.id)}
>
<span className="block text-[13px] font-medium">{section.label}</span>
<span className="mt-0.5 block text-[11px] text-gray-600">{section.detail}</span>
</button>
))}
</div>
</aside>
<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"
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>
<main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8">
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3>
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p>
{activeSection === "workspace" ? (
<div className="mt-8 space-y-9">
<SettingsGroup title="Model">
<SettingsItem
label={
<>
WD SwinV2 Tagger v3{" "}
<span className="ml-1.5 align-middle">
<StatusPill tone={taggerReady ? "ready" : taggerModelPreparing ? "busy" : "muted"}>
{taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"}
</StatusPill>
</span>
</>
}
description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds."
>
<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"
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"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : (
<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"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button>
)}
</div>
</SettingsItem>
<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">
{(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton
key={acceleration}
acceleration={acceleration}
current={taggerAcceleration}
onSelect={(nextAcceleration) => {
setTaggerAccelerationSaving(true);
setTaggerAccelerationError(null);
void setTaggerAcceleration(nextAcceleration)
.catch((error: unknown) => setTaggerAccelerationError(String(error)))
.finally(() => setTaggerAccelerationSaving(false));
}}
>
{acceleration === "directml" ? "DirectML" : acceleration === "cpu" ? "CPU" : "Auto"}
</TaggerAccelerationButton>
))}
</div>
{taggerAccelerationError ? (
<p className="text-[11px] text-amber-300">{taggerAccelerationError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Confidence threshold" description="Lower values keep more tags. Higher values are stricter and usually cleaner.">
<div className="flex flex-col items-end gap-1.5">
<input
type="number"
min="0.05"
max="0.99"
step="0.05"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={() => {
const value = parseFloat(thresholdDisplay);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null);
setTaggerThresholdSaving(true);
void setTaggerThreshold(value)
.catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => {
setTaggerThresholdDraft(null);
setTaggerThresholdSaving(false);
});
} else {
setTaggerThresholdDraft(null);
setTaggerThresholdError("Must be 0.05 0.99");
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000);
}
}}
/>
{taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Tagging batch size" description="Number of images processed concurrently during tag generation.">
<div className="flex flex-col items-end gap-1.5">
<input
type="number"
min="1"
max="100"
step="1"
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={batchSizeDisplay}
onChange={(event) => setTaggerBatchSizeDraft(event.target.value)}
onBlur={() => {
const value = parseInt(batchSizeDisplay, 10);
if (!isNaN(value) && value >= 1 && value <= 100) {
setTaggerBatchSizeError(null);
setTaggerBatchSizeSaving(true);
void setTaggerBatchSize(value)
.catch((error: unknown) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeSaving(false);
});
} else {
setTaggerBatchSizeDraft(null);
setTaggerBatchSizeError("Must be 1 100");
if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current);
batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000);
}
}}
/>
{taggerBatchSizeError ? (
<p className="text-[11px] text-amber-300">{taggerBatchSizeError}</p>
) : (
<p className="text-[11px] text-gray-600">{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}</p>
)}
</div>
</SettingsItem>
<SettingsItem label="Model location" vertical>
<div>
<p className="break-all font-mono text-xs text-gray-600">
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
</p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? (
<div className="mt-3">
<p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
</p>
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div>
</SettingsItem>
</SettingsGroup>
<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">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div>
</SettingsItem>
<div className="py-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-white">Folder selection</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">Current target: {queueScopeLabel}.</p>
</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"
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"
onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
>
Clear
</button>
</div>
</div>
<div className={`mt-2 max-h-64 divide-y divide-white/[0.04] overflow-y-auto pr-1 ${taggingQueueScope === "selected" ? "opacity-100" : "opacity-60"}`}>
{folders.map((folder) => {
const active = taggingQueueFolderIds.includes(folder.id);
const progress = mediaJobProgress[folder.id];
return (
<button
key={folder.id}
type="button"
className={`flex w-full items-center justify-between gap-3 px-1 py-2 text-left transition-colors disabled:cursor-not-allowed ${
active ? "text-white" : "text-gray-400 hover:text-gray-200"
}`}
onClick={() => toggleTaggingQueueFolder(folder.id)}
disabled={taggingQueueScope === "all"}
>
<p className="min-w-0 truncate text-sm">{folder.name}</p>
<div className="flex shrink-0 items-center gap-3">
{(progress?.tagging_pending ?? 0) > 0 ? <StatusPill tone="busy">{progress?.tagging_pending} queued</StatusPill> : null}
<span className="text-[11px] tabular-nums text-gray-600">{folder.image_count.toLocaleString()} items</span>
<span className={`h-4 w-4 rounded-full border ${active ? "border-emerald-300 bg-emerald-300" : "border-white/15 bg-transparent"}`} />
</div>
</button>
);
})}
{folders.length === 0 ? <p className="py-2 text-sm text-gray-500">Add a folder first to enable targeted tagging queues.</p> : null}
</div>
</div>
<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"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (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"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
</div>
</SettingsItem>
{taggerQueueStatus ? <p className="pt-3 text-xs text-gray-500">{taggerQueueStatus}</p> : null}
</SettingsGroup>
</div>
) : (
<div className="mt-8 space-y-9">
<SettingsGroup title="Updates">
<SettingsItem
label={
<span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? (
<StatusPill tone="ready">Up to date</StatusPill>
) : null}
</span>
}
description={
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."
) : (
"Updates are checked quietly at launch and installed only when you choose."
)
}
>
{updateStatus === "available" ? (
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => void installUpdate()}
>
Install &amp; restart
</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"
onClick={() => void checkForUpdates()}
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
>
{updateStatus === "checking" ? "Checking..." : "Check for updates"}
</button>
)}
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Setup">
<FfmpegStatusRow />
<SettingsItem
label="Welcome tour"
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"
onClick={() => {
setSettingsOpen(false);
openOnboarding();
}}
>
Show welcome tour
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Storage & notifications">
<SettingsItem
label="App data folder"
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"
onClick={() => {
setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false));
}}
disabled={openingDataFolder}
>
{openingDataFolder ? "Opening..." : "Open data folder"}
</button>
</SettingsItem>
<SettingsItem
label="Pause all notifications"
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
>
<button
role="switch"
aria-checked={notificationsPaused}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setNotificationsPaused(!notificationsPaused)}
>
<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>
</SettingsGroup>
<SettingsGroup title="Maintenance">
<SettingsItem
label="Compact database"
description={
<>
<span>Reclaims wasted space left behind when images or tags are deleted. Safe to run at any time.</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Size"
value={
vacuumResult
? `${vacuumResult.after_mb.toFixed(1)} MB`
: dbInfo
? `${dbInfo.size_mb.toFixed(1)} MB`
: "—"
}
/>
<StatPair
label="Reclaimable"
accent={vacuumResult !== null}
value={
vacuumResult
? `${vacuumResult.freed_mb.toFixed(1)} MB freed`
: dbInfo
? `${dbInfo.reclaimable_mb.toFixed(1)} MB`
: "—"
}
/>
</span>
<span className="mt-2 block text-gray-600">
{vacuumResult
? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.`
: dbInfo && dbInfo.reclaimable_mb < 0.5
? "Database is already compact."
: "Run this after removing folders or bulk-deleting images."}
</span>
</>
}
>
<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"
onClick={() => {
setVacuuming(true);
setVacuumResult(null);
void vacuumDatabase()
.then((result) => {
setVacuumResult(result);
setDbInfo({ size_mb: result.after_mb, reclaimable_mb: 0 });
})
.catch(() => {})
.finally(() => setVacuuming(false));
}}
disabled={vacuuming || (dbInfo !== null && dbInfo.reclaimable_mb < 0.5)}
>
{vacuuming ? "Compacting..." : "Compact now"}
</button>
</SettingsItem>
<SettingsItem
label="Thumbnail cache"
description={
<>
<span>Thumbnails left behind when folders or images are removed. Safe to delete they are regenerated if the originals are re-indexed.</span>
<span className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-1">
<StatPair
label="Orphaned files"
value={
thumbnailCleanupResult
? "0"
: thumbnailInfo
? thumbnailInfo.count.toLocaleString()
: "—"
}
/>
<StatPair
label="Reclaimable"
accent={thumbnailCleanupResult !== null}
value={
thumbnailCleanupResult
? `${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
: thumbnailInfo
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
: "—"
}
/>
</span>
<span className="mt-2 block text-gray-600">
{cleaningThumbnails
? "Scanning and removing orphaned thumbnails…"
: thumbnailCleanupResult
? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} file${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}, freed ${thumbnailCleanupResult.freed_mb.toFixed(1)} MB.`
: thumbnailInfo && thumbnailInfo.count === 0
? "No orphaned thumbnails found."
: thumbnailInfo && thumbnailInfo.count > 1000
? "May take a few minutes for large collections."
: "Remove thumbnails no longer associated with any indexed image."}
</span>
</>
}
>
<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"
onClick={() => {
setCleaningThumbnails(true);
cleanupOrphanedThumbnails()
.then((result) => {
setThumbnailCleanupResult(result);
setThumbnailInfo(null);
})
.catch(() => {})
.finally(() => setCleaningThumbnails(false));
}}
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
>
{cleaningThumbnails ? "Cleaning…" : "Clean up"}
</button>
</SettingsItem>
</SettingsGroup>
</div>
)}
</div>
</main>
</div>
</div>
);
}
+256 -11
View File
@@ -1,6 +1,83 @@
import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store"; import { useGalleryStore, Folder, IndexProgress } from "../store";
interface ContextMenuState {
folderId: number;
x: number;
y: number;
}
function FolderContextMenu({
menu,
folder,
isMuted,
isPausedAll,
onClose,
onRename,
onReindex,
onLocate,
onToggleMute,
onTogglePauseAll,
onRemove,
}: {
menu: ContextMenuState;
folder: Folder;
isMuted: boolean;
isPausedAll: boolean;
onClose: () => void;
onRename: () => void;
onReindex: () => void;
onLocate: () => void;
onToggleMute: () => void;
onTogglePauseAll: () => void;
onRemove: () => void;
}) {
const ref = useRef<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={() => { 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: menu.x, top: menu.y }}
>
{item("Reindex", onReindex)}
{item("Rename", onRename)}
{item(isPausedAll ? "Resume background work" : "Pause background work", onTogglePauseAll)}
{item(isMuted ? "Unmute notifications" : "Mute notifications", onToggleMute)}
{folder.scan_error && item("Locate Folder", onLocate)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Remove from app", onRemove, true)}
</div>
);
}
function FolderItem({ function FolderItem({
folder, folder,
selected, selected,
@@ -10,27 +87,100 @@ function FolderItem({
selected: boolean; selected: boolean;
progress: IndexProgress | undefined; progress: IndexProgress | undefined;
}) { }) {
const { selectFolder, removeFolder, reindexFolder } = useGalleryStore(); const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
const folderWorkers = useGalleryStore((state) => state.workerPaused[folder.id]);
const isMuted = mutedFolderIds.includes(folder.id);
// "Fully paused" only when every worker for this folder is paused.
const isPausedAll = !!folderWorkers &&
folderWorkers.thumbnail && folderWorkers.metadata && folderWorkers.embedding && folderWorkers.tagging;
const isIndexing = progress && !progress.done; const isIndexing = progress && !progress.done;
const isMissing = !!folder.scan_error && !isIndexing;
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(folder.name);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(folder.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, folder.name]);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
// Keep menu inside viewport
const x = Math.min(e.clientX, window.innerWidth - 180);
const y = Math.min(e.clientY, window.innerHeight - 160);
setContextMenu({ folderId: folder.id, x, y });
};
const handleLocateFolder = async () => {
const picked = await open({ directory: true, multiple: false, title: `Locate "${folder.name}"` });
if (picked && typeof picked === "string") {
await updateFolderPath(folder.id, picked);
}
};
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== folder.name) {
await renameFolder(folder.id, trimmed);
}
setRenaming(false);
};
const handleRenameKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") { setRenaming(false); }
};
return ( return (
<>
<div <div
className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${ className={`group relative flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
selected selected
? "bg-white/8 text-white" ? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5" : "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`} }`}
onClick={() => selectFolder(folder.id)} onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu}
> >
{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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
) : (
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /> d="M3 7a2 2 0 012-2h3.586a1 1 0 01.707.293l1.414 1.414A1 1 0 0011.414 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg> </svg>
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{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={handleRenameKey}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}> <div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{folder.name} {folder.name}
</div> </div>
)}
{isIndexing ? ( {isIndexing ? (
<> <>
<div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div> <div className="text-[11px] text-gray-600 mt-0.5">{progress.indexed}/{progress.total}</div>
@@ -46,28 +196,89 @@ function FolderItem({
)} )}
</div> </div>
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"> {/* Hover action buttons */}
{!renaming && (
confirmingRemoval ? (
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button <button
className="p-1 rounded-md hover:bg-white/10 text-gray-500 hover:text-gray-200" 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"
title="Re-index" onClick={() => { void removeFolder(folder.id); setConfirmingRemoval(false); }}
onClick={(e) => { e.stopPropagation(); reindexFolder(folder.id); }} >
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>
) : (
<div className="flex items-center gap-0.5 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<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"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
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" /> 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> </svg>
</button> </button>
<button <button
className="p-1 rounded-md hover:bg-red-500/15 text-gray-500 hover:text-red-400" className="p-1 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition-colors"
title="Remove folder" title="Remove from app"
onClick={(e) => { e.stopPropagation(); removeFolder(folder.id); }} onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
> >
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
)
)}
</div> </div>
{isMissing && (
<div className="mx-2 mb-1 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
<p className="text-[11px] text-amber-400 font-medium mb-1.5">Folder not found</p>
<p className="text-[10px] text-gray-500 mb-2 leading-snug">
This folder may have been moved or renamed. Locate it to resume, or remove it from the app.
</p>
<div className="flex gap-1.5">
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-amber-500/20 text-amber-300 hover:bg-amber-500/30 hover:text-amber-200 transition-colors"
onClick={(e) => { e.stopPropagation(); void handleLocateFolder(); }}
>
Locate Folder
</button>
<button
className="flex-1 px-2 py-1 rounded-md text-[10px] font-medium bg-white/5 text-gray-400 hover:bg-red-500/15 hover:text-red-400 transition-colors"
onClick={(e) => { e.stopPropagation(); setConfirmingRemoval(true); }}
>
Remove
</button>
</div>
</div>
)}
{contextMenu && contextMenu.folderId === folder.id && (
<FolderContextMenu
menu={contextMenu}
folder={folder}
isMuted={isMuted}
isPausedAll={isPausedAll}
onClose={() => setContextMenu(null)}
onRename={() => setRenaming(true)}
onReindex={() => void reindexFolder(folder.id)}
onLocate={() => void handleLocateFolder()}
onToggleMute={() => toggleMutedFolder(folder.id)}
onTogglePauseAll={() => setAllWorkersPaused(folder.id, !isPausedAll)}
onRemove={() => setConfirmingRemoval(true)}
/>
)}
</>
); );
} }
@@ -138,6 +349,40 @@ export function Sidebar() {
Explore Explore
</span> </span>
</div> </div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "timeline"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("timeline")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "timeline" ? "text-white" : ""}`}>
Timeline
</span>
</div>
<div
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
activeView === "duplicates"
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => setView("duplicates")}
>
<svg className="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span className={`text-[13px] font-medium ${activeView === "duplicates" ? "text-white" : ""}`}>
Duplicates
</span>
</div>
</div> </div>
{/* Section label */} {/* Section label */}
+322 -186
View File
@@ -1,242 +1,378 @@
import { useEffect } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, TagCloudEntry } from "../store"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
// Accent glow colours for the hover ring — cycled by index const ACCENTS = [
const GLOWS: string[] = [ "#60a5fa",
"rgba(59,130,246,0.5)", "#c084fc",
"rgba(168,85,247,0.5)", "#4ade80",
"rgba(16,185,129,0.5)", "#fbbf24",
"rgba(245,158,11,0.5)", "#f472b4",
"rgba(236,72,153,0.5)", "#2dd4bf",
"rgba(6,182,212,0.5)", "#fb923c",
"rgba(249,115,22,0.5)", "#a78bfa",
"rgba(34,197,94,0.5)", "#34d399",
"#f87171",
]; ];
function pseudoRandom(seed: number): number { const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
const x = Math.sin(seed + 1) * 10000;
function seeded(n: number): number {
const x = Math.sin(n * 9301 + 49297) * 233280;
return x - Math.floor(x); return x - Math.floor(x);
} }
// Map cluster size to a tile size bucket (px) interface PlacedNode {
function getTileSize(count: number, maxCount: number): number {
if (maxCount === 0) return 72;
const ratio = count / maxCount;
if (ratio > 0.75) return 160;
if (ratio > 0.45) return 128;
if (ratio > 0.22) return 104;
if (ratio > 0.08) return 88;
return 72;
}
function TagButton({
entry,
index,
maxCount,
onSearch,
}: {
entry: TagCloudEntry; entry: TagCloudEntry;
index: number; index: number;
maxCount: number; x: number;
onSearch: (imageId: number) => void; y: number;
}) { w: number;
const size = getTileSize(entry.count, maxCount); h: number;
const glow = GLOWS[index % GLOWS.length]; accent: string;
driftX: number;
driftY: number;
driftDuration: number;
rotateSeed: number;
}
// Small random rotation for organic feel — larger tiles stay flatter function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: number): PlacedNode[] {
const maxRot = size >= 128 ? 0 : size >= 104 ? 3 : size >= 88 ? 6 : 10; if (!entries.length || containerW <= 0 || containerH <= 0) return [];
const rotation = (pseudoRandom(index * 7) - 0.5) * 2 * maxRot;
const mt = Math.floor(pseudoRandom(index * 3) * 10) + 4; const maxCount = Math.max(...entries.map((e) => e.count));
const mr = Math.floor(pseudoRandom(index * 5) * 12) + 4; const cx = containerW / 2;
const mb = Math.floor(pseudoRandom(index * 11) * 10) + 4; const cy = containerH / 2;
const ml = Math.floor(pseudoRandom(index * 13) * 12) + 4; // 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;
const src = entry.thumbnail_path ? convertFileSrc(entry.thumbnail_path) : null; // 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 ( return (
<motion.button <motion.button
initial={{ opacity: 0, scale: 0.5, rotate: rotation * 2 }} 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"
animate={{ opacity: 1, scale: 1, rotate: rotation }} 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={{ transition={{
delay: index * 0.025, opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
type: "spring", scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) },
stiffness: 200, x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 },
damping: 18, 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.12,
rotate: 0,
transition: { type: "spring", stiffness: 400, damping: 22 },
}}
whileTap={{ scale: 0.92 }}
onClick={() => onSearch(entry.representative_image_id)}
title={`${entry.count} similar ${entry.count === 1 ? "photo" : "photos"}`}
style={{
width: size,
height: size,
margin: `${mt}px ${mr}px ${mb}px ${ml}px`,
borderRadius: 12,
border: "2px solid rgba(255,255,255,0.08)",
background: "rgba(255,255,255,0.04)",
cursor: "pointer",
padding: 0,
overflow: "hidden",
position: "relative",
flexShrink: 0,
boxShadow: "none",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
onMouseEnter={(e) => {
const el = e.currentTarget;
el.style.borderColor = glow;
el.style.boxShadow = `0 0 16px ${glow}, 0 0 32px ${glow.replace("0.5", "0.25")}`;
}}
onMouseLeave={(e) => {
const el = e.currentTarget;
el.style.borderColor = "rgba(255,255,255,0.08)";
el.style.boxShadow = "none";
}} }}
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 ? ( {src ? (
<img <img
src={src} src={src}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false} draggable={false}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/> />
) : ( ) : (
// Fallback placeholder when no thumbnail exists yet <div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
<div
style={{
width: "100%",
height: "100%",
background: "rgba(255,255,255,0.06)",
}}
/>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Count badge — bottom-right corner */} {/* Accent glow on hover */}
<div <div
style={{ className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
position: "absolute", style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
bottom: 5, />
right: 5, <div className="absolute inset-x-0 bottom-0 p-3">
background: "rgba(0,0,0,0.6)", <div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
color: "rgba(255,255,255,0.85)", <div className="flex items-end justify-between gap-2">
fontSize: 10, <div>
fontWeight: 600, <p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
lineHeight: 1, <p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
padding: "3px 6px", </div>
borderRadius: 6, <span
backdropFilter: "blur(4px)", 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"
pointerEvents: "none", style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
}}
> >
{entry.count} Open
</span>
</div>
</div> </div>
</motion.button> </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() { export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode);
const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries); const tagCloudEntries = useGalleryStore((state) => state.tagCloudEntries);
const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading); const tagCloudLoading = useGalleryStore((state) => state.tagCloudLoading);
const loadTagCloud = useGalleryStore((state) => state.loadTagCloud); const loadTagCloud = useGalleryStore((state) => state.loadTagCloud);
const searchByTag = useGalleryStore((state) => state.searchByTag); 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); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
useEffect(() => { useEffect(() => {
void loadTagCloud(); if (exploreMode === "visual") void loadTagCloud();
}, [selectedFolderId]); else void loadExploreTags();
}, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]);
const maxCount = const { logMin, logRange } = useMemo(() => {
tagCloudEntries.length > 0 if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 };
? Math.max(...tagCloudEntries.map((e) => e.count)) const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1)));
: 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 ( return (
<div className="flex-1 flex flex-col items-center min-h-0 overflow-y-auto"> <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 */} {/* Header */}
<motion.div <div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
className="text-center pt-14 pb-8 shrink-0" <div className="flex items-center justify-between gap-4">
initial={{ opacity: 0, y: -10 }} <div className="min-w-0">
animate={{ opacity: 1, y: 0 }} <h2 className="text-[15px] font-semibold text-white">Explore</h2>
transition={{ duration: 0.35 }} <p className="mt-0.5 truncate text-[11px] text-white/30">
> {loading
<h2 className="text-[22px] font-semibold text-white/70 tracking-tight mb-2"> ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
Explore your library : hasEntries
</h2> ? exploreMode === "visual"
<p className="text-[13px] text-white/25"> ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open`
Visual clusters from your photos sized by how many match : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search`
</p> : exploreMode === "visual"
</motion.div> ? "No clusters — images need embeddings first"
: "No tags — run the AI tagger or add tags manually"}
{/* Loading */}
{tagCloudLoading && (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<motion.svg
className="w-8 h-8 text-white/20"
viewBox="0 0 24 24"
fill="none"
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="2"
strokeOpacity="0.2"
/>
<path
d="M4 12a8 8 0 018-8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</motion.svg>
<p className="text-[12px] text-white/20">Clustering your library</p>
</div>
)}
{/* Empty state */}
{!tagCloudLoading && tagCloudEntries.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<p className="text-[13px] text-white/25 text-center max-w-xs leading-relaxed">
No embeddings yet. Add a folder and wait for the embedding worker to
finish, then come back here.
</p> </p>
</div> </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>
{/* Cluster grid */} {loading ? (
{!tagCloudLoading && tagCloudEntries.length > 0 && ( <div className="flex flex-1 items-center justify-center gap-3 text-white/25">
<div className="flex flex-wrap justify-center px-12 pb-16 max-w-5xl w-full"> <Spinner />
{tagCloudEntries.map((entry, index) => ( <span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
<TagButton </div>
key={entry.representative_image_id} ) : !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} entry={entry}
index={index} index={index}
maxCount={maxCount} logMin={logMin}
onSearch={searchByTag} logRange={logRange}
onSearch={searchForTag}
/> />
))} ))}
</div> </div>
)} </div>
{!tagCloudLoading && tagCloudEntries.length > 0 && (
<p className="shrink-0 pb-8 text-[11px] text-white/12 text-center">
Grouped by visual similarity · CLIP ViT-B/32
</p>
)} )}
</div> </div>
); );
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { ImageRecord, tileSizeForZoom, useGalleryStore } from "../store";
import { ContextMenu, ImageTile } from "./Gallery";
const GAP = 6;
const HEADER_HEIGHT = 52;
interface TimelineGroup {
key: string;
label: string;
images: ImageRecord[];
}
function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-");
const year = Number(yearStr);
const month = Number(monthStr);
if (!isFinite(year) || !isFinite(month) || month < 1 || month > 12) return "Unknown Date";
const date = new Date(year, month - 1);
if (isNaN(date.getTime())) return "Unknown Date";
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
}
function groupImages(images: ImageRecord[]): TimelineGroup[] {
const map = new Map<string, ImageRecord[]>();
for (const img of images) {
const ds = img.taken_at ?? img.modified_at;
const key = ds ? ds.substring(0, 7) : "unknown";
let bucket = map.get(key);
if (bucket === undefined) {
bucket = [];
map.set(key, bucket);
}
bucket.push(img);
}
return Array.from(map.entries())
.sort(([a], [b]) => {
if (a === "unknown") return 1;
if (b === "unknown") return -1;
return a < b ? -1 : a > b ? 1 : 0;
})
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
}
export function Timeline() {
const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
const openImage = useGalleryStore((s) => s.openImage);
const totalImages = useGalleryStore((s) => s.totalImages);
const loadingImages = useGalleryStore((s) => s.loadingImages);
const imageLoadError = useGalleryStore((s) => s.imageLoadError);
const zoomPreset = useGalleryStore((s) => s.zoomPreset);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = 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.
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 groups = useMemo(() => groupImages(images), [images]);
// 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],
);
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 2,
});
// 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).
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
const handleScroll = useCallback(() => {
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 el = parentRef.current;
if (!el) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [handleScroll]);
useEffect(() => {
const close = (e: PointerEvent) => {
if ((e.target as HTMLElement | null)?.closest("[data-gallery-context-menu]")) return;
setContextMenu(null);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setContextMenu(null);
};
window.addEventListener("pointerdown", close);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", close);
window.removeEventListener("keydown", onKey);
};
}, []);
return (
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"
>
{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">
<div className="h-5 w-5 mx-auto rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
<p className="mt-4 text-sm text-white/40 font-medium">Loading timeline</p>
<p className="text-xs text-white/20 mt-1">Fetching results</p>
</div>
</div>
) : images.length === 0 ? (
<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">
<svg
className="h-12 w-12 mx-auto text-white/10 mb-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={0.75}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-sm text-white/30 font-medium">
{imageLoadError ? "Could not load timeline" : "No media found"}
</p>
<p className="text-xs text-white/15 mt-1">
{imageLoadError ?? "Add a folder to see your timeline"}
</p>
</div>
</div>
) : (
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const group = groups[virtualItem.index];
if (!group) return null;
return (
<div
key={virtualItem.key}
style={{
position: "absolute",
top: virtualItem.start,
width: "100%",
height: virtualItem.size,
}}
>
{/* Group 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}
</span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{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",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
}}
>
{group.images.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
</div>
);
})}
</div>
)}
{images.length > 0 && loadingImages ? (
<div className="flex justify-center py-8">
<div className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white/60 animate-spin" />
</div>
) : null}
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
image={contextMenu.image}
onClose={() => setContextMenu(null)}
/>
) : null}
</div>
);
}
+45 -7
View File
@@ -1,5 +1,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark";
// SVG icons for window controls // SVG icons for window controls
function MinimizeIcon() { function MinimizeIcon() {
@@ -37,6 +39,10 @@ function CloseIcon() {
export function TitleBar() { export function TitleBar() {
const [isMaximized, setIsMaximized] = useState(false); const [isMaximized, setIsMaximized] = useState(false);
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion);
const installUpdate = useGalleryStore((state) => state.installUpdate);
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
useEffect(() => { useEffect(() => {
@@ -57,6 +63,10 @@ export function TitleBar() {
const handleMaximize = () => appWindow.toggleMaximize(); const handleMaximize = () => appWindow.toggleMaximize();
const handleClose = () => appWindow.close(); const handleClose = () => appWindow.close();
// An update is waiting for the user to act. Covers the "clicked Later" case too,
// since dismissing the toast doesn't change updateStatus.
const updatePending = updateStatus === "available";
return ( return (
// data-tauri-drag-region is the recommended Tauri approach for drag regions. // data-tauri-drag-region is the recommended Tauri approach for drag regions.
// WebkitAppRegion is kept as a CSS fallback for compatibility. // WebkitAppRegion is kept as a CSS fallback for compatibility.
@@ -65,15 +75,32 @@ export function TitleBar() {
className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none" className="titlebar relative z-50 flex h-9 shrink-0 items-center bg-gray-950 select-none"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties} style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
> >
{/* App icon + name — left side */} {/* App icon + name — left side. When an update is waiting, the iris lights
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"> <div className="flex items-center gap-2 pl-3 pr-4">
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden"> {updatePending ? (
{/* Phokus logo placeholder — replace with <img src={logo} /> if you have an SVG */} <div
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"> className="group relative"
<circle cx="6" cy="6" r="4" stroke="#a78bfa" strokeWidth="1.5" /> style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
<circle cx="6" cy="6" r="1.5" fill="#a78bfa" /> >
</svg> <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" />
<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>
</div> </div>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
<PhokusMark className="h-4 w-4" />
</div>
)}
<span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span> <span className="text-[11px] font-semibold text-gray-400 tracking-wide">Phokus</span>
</div> </div>
@@ -85,6 +112,17 @@ export function TitleBar() {
className="flex items-stretch h-full" className="flex items-stretch h-full"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
> >
<button
onClick={() => setSettingsOpen(true)}
title="Settings"
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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.607 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{/* Minimize */} {/* Minimize */}
<button <button
onClick={handleMinimize} onClick={handleMinimize}
+195 -51
View File
@@ -1,11 +1,17 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchMode } from "../store"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown";
const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [
{ value: "date_desc", label: "Newest first" }, { value: "date_desc", label: "Newest first" },
{ value: "date_asc", label: "Oldest first" }, { value: "date_asc", label: "Oldest first" },
{ value: "taken_desc", label: "Taken: newest" },
{ value: "taken_asc", label: "Taken: oldest" },
{ value: "name_asc", label: "Name AZ" }, { value: "name_asc", label: "Name AZ" },
{ value: "name_desc", label: "Name ZA" }, { value: "name_desc", label: "Name ZA" },
{ value: "rating_desc", label: "Highest rated" },
{ value: "rating_asc", label: "Lowest rated" },
{ value: "size_desc", label: "Largest first" }, { value: "size_desc", label: "Largest first" },
{ value: "size_asc", label: "Smallest first" }, { value: "size_asc", label: "Smallest first" },
]; ];
@@ -116,12 +122,27 @@ function FilterPill({
); );
} }
function commandPrefix(command: SearchCommand | null): string | null {
switch (command) {
case "semantic":
return "/s";
case "tag":
return "/t";
default:
return null;
}
}
function composeSearchValue(command: SearchCommand | null, query: string): string {
const prefix = commandPrefix(command);
if (!prefix) return query;
return query.length > 0 ? `${prefix} ${query}` : prefix;
}
export function Toolbar() { export function Toolbar() {
const search = useGalleryStore((state) => state.search); const search = useGalleryStore((state) => state.search);
const setSearch = useGalleryStore((state) => state.setSearch); const setSearch = useGalleryStore((state) => state.setSearch);
const clearSearch = useGalleryStore((state) => state.clearSearch); const clearSearch = useGalleryStore((state) => state.clearSearch);
const searchMode = useGalleryStore((state) => state.searchMode);
const setSearchMode = useGalleryStore((state) => state.setSearchMode);
const sort = useGalleryStore((state) => state.sort); const sort = useGalleryStore((state) => state.sort);
const setSort = useGalleryStore((state) => state.setSort); const setSort = useGalleryStore((state) => state.setSort);
const totalImages = useGalleryStore((state) => state.totalImages); const totalImages = useGalleryStore((state) => state.totalImages);
@@ -133,20 +154,27 @@ export function Toolbar() {
const setMediaFilter = useGalleryStore((state) => state.setMediaFilter); const setMediaFilter = useGalleryStore((state) => state.setMediaFilter);
const favoritesOnly = useGalleryStore((state) => state.favoritesOnly); const favoritesOnly = useGalleryStore((state) => state.favoritesOnly);
const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly); const setFavoritesOnly = useGalleryStore((state) => state.setFavoritesOnly);
const minimumRating = useGalleryStore((state) => state.minimumRating);
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating);
const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly);
const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
const activeView = useGalleryStore((state) => state.activeView);
const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0);
const [searchValue, setSearchValue] = useState(search); const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const [tagSuggestions, setTagSuggestions] = useState<ExploreTagEntry[]>([]);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggestDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
// Tracks whether the user has typed in the search box at least once. const searchShellRef = useRef<HTMLDivElement>(null);
// Prevents the debounce effect from dispatching setSearch on initial mount
// when searchValue === search (which would wipe a loadSimilarImages result).
const userHasTyped = useRef(false); const userHasTyped = useRef(false);
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId); const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
@@ -154,11 +182,8 @@ export function Toolbar() {
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset);
const sortOptions = getSortOptions(mediaFilter); const sortOptions = getSortOptions(mediaFilter);
const hasActiveSearch = search.trim().length > 0; const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const searchModes: { value: SearchMode; label: string }[] = [ const isSimilarResults = collectionTitle === "Similar Images";
{ value: "filename", label: "Filename" },
{ value: "semantic", label: "Semantic" },
];
// If current sort is video-only but we switched away from video filter, reset to date_desc // If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => { useEffect(() => {
@@ -170,35 +195,62 @@ export function Toolbar() {
useEffect(() => { useEffect(() => {
if (!userHasTyped.current) return; if (!userHasTyped.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { setSearch(searchValue); }, 200); debounceRef.current = setTimeout(() => { setSearch(composeSearchValue(searchCommand, searchQuery)); }, 200);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [searchValue, setSearch]); }, [searchCommand, searchQuery, setSearch]);
useEffect(() => { useEffect(() => {
setSearchValue(search); const parsed = parseSearchValue(search);
setSearchCommand(parsed.prefix && parsed.mode !== "filename" ? parsed.mode : null);
setSearchQuery(parsed.prefix ? parsed.query : search);
}, [search]); }, [search]);
// Fetch tag suggestions when in tag mode
useEffect(() => {
if (searchCommand !== "tag") {
setTagSuggestions([]);
return;
}
if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current);
suggestDebounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 },
});
setTagSuggestions(results);
} catch {
setTagSuggestions([]);
}
}, 120);
return () => { if (suggestDebounceRef.current) clearTimeout(suggestDebounceRef.current); };
}, [searchCommand, searchQuery, selectedFolderId]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
const isModeToggle = (event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === "s";
if (isModeToggle) {
event.preventDefault();
setSearchMode(searchMode === "semantic" ? "filename" : "semantic");
searchInputRef.current?.focus();
return;
}
const activeElement = document.activeElement; const activeElement = document.activeElement;
const searchFocused = activeElement === searchInputRef.current; const searchFocused = activeElement === searchInputRef.current;
if (event.key === "Escape" && (searchFocused || hasActiveSearch)) { if (event.key === "Escape" && (searchFocused || hasActiveSearch)) {
event.preventDefault(); event.preventDefault();
setSearchValue(""); setSearchCommand(null);
setSearchQuery("");
clearSearch(); clearSearch();
} }
}; };
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [clearSearch, hasActiveSearch, searchMode, setSearchMode]); }, [clearSearch, hasActiveSearch]);
useEffect(() => {
const close = (event: PointerEvent) => {
if (searchShellRef.current?.contains(event.target as Node)) return;
setSearchPanelOpen(false);
};
window.addEventListener("pointerdown", close);
return () => window.removeEventListener("pointerdown", close);
}, []);
const showTagSuggestions = searchCommand === "tag" && searchPanelOpen;
const showCommandHints = !searchCommand && searchPanelOpen;
return ( 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-20 shrink-0 border-b border-white/[0.06] bg-gray-950/80 backdrop-blur-xl">
@@ -212,33 +264,20 @@ export function Toolbar() {
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
: totalImages.toLocaleString()} : totalImages.toLocaleString()}
</span> </span>
{(hasActiveSearch || searchMode === "semantic") && ( {hasActiveSearch && (
<span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0"> <span className="rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-0.5 text-[10px] uppercase tracking-[0.14em] text-blue-300 shrink-0">
{searchMode === "semantic" ? "Semantic Search" : "Filename Search"} {searchModeLabel(parsedSearch.mode)}
</span> </span>
)} )}
</div> </div>
{activeView === "timeline" ? <FolderScopeDropdown /> : null}
<div className="flex-1" /> <div className="flex-1" />
{/* Search */} {/* Search */}
<div ref={searchShellRef} className="relative">
<div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden"> <div className="flex items-center rounded-lg border border-white/8 bg-white/5 overflow-hidden">
<div className="flex items-center pl-2 pr-1 gap-1 border-r border-white/8">
{searchModes.map((mode) => (
<button
key={mode.value}
className={`rounded-md px-2 py-1 text-[11px] transition-colors ${
searchMode === mode.value
? "bg-white/10 text-white"
: "text-gray-500 hover:text-gray-200"
}`}
title={mode.value === "semantic" ? "Toggle with Ctrl/Cmd+Shift+S" : "Toggle with Ctrl/Cmd+Shift+S"}
onClick={() => setSearchMode(mode.value)}
>
{mode.label}
</button>
))}
</div>
<div className="relative"> <div className="relative">
<svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600" <svg className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-600"
fill="none" viewBox="0 0 24 24" stroke="currentColor"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -248,20 +287,50 @@ export function Toolbar() {
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={searchValue} value={searchQuery}
onChange={(event) => { onChange={(event) => {
userHasTyped.current = true; userHasTyped.current = true;
setSearchValue(event.target.value); const nextValue = event.target.value;
if (!searchCommand) {
const parsed = parseSearchValue(nextValue);
if (parsed.prefix) {
setSearchCommand(parsed.mode === "filename" ? null : parsed.mode);
setSearchQuery(parsed.query);
return;
}
}
setSearchQuery(nextValue);
}} }}
placeholder={searchMode === "semantic" ? "Search by meaning..." : "Search filenames..."} onKeyDown={(event) => {
className="w-64 bg-transparent py-1.5 pl-8 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors" if (event.key === "Backspace" && searchQuery.length === 0 && searchCommand !== null) {
event.preventDefault();
setSearchCommand(null);
}
}}
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"}`}
/> />
{searchValue.trim().length > 0 && ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2">
<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>
</div>
) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? (
<button <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" 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" title="Clear search"
onClick={() => { onClick={() => {
setSearchValue(""); setSearchCommand(null);
setSearchQuery("");
setTagSuggestions([]);
clearSearch(); clearSearch();
}} }}
> >
@@ -269,10 +338,80 @@ export function Toolbar() {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
)} ) : null}
</div> </div>
</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">
{tagSuggestions.map((entry) => (
<button
key={entry.tag}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
// mousedown fires before input blur, so we prevent losing focus
e.preventDefault();
userHasTyped.current = true;
setSearchQuery(entry.tag);
setSearch(`/t ${entry.tag}`);
setSearchPanelOpen(false);
searchInputRef.current?.blur();
}}
>
<span className="text-sm text-white/88">{entry.tag}</span>
<span className="shrink-0 text-[11px] tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
</button>
))}
</div>
) : null}
{/* 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">
<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">
<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">
{(
[
{ 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" },
] as const
).map((option) => (
<button
key={option.prefix}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-white/[0.05]"
onMouseDown={(e) => {
e.preventDefault();
userHasTyped.current = true;
setSearchCommand(option.command);
searchInputRef.current?.focus();
}}
>
<span className="rounded border border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-gray-400">
{option.prefix}
</span>
<div>
<p className="text-sm text-gray-200">{option.label}</p>
<p className="text-xs text-gray-500">{option.description}</p>
</div>
</button>
))}
</div>
) : null}
</div>
{/* Sort */} {/* Sort */}
<SortDropdown value={sort} onChange={setSort} options={sortOptions} /> <SortDropdown value={sort} onChange={setSort} options={sortOptions} />
@@ -302,10 +441,14 @@ export function Toolbar() {
{/* Filter row */} {/* Filter row */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <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="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="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="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); }} />
<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 ? ( {hasAnyFailedEmbeddings ? (
<FilterPill <FilterPill
label="Failed Embeddings" label="Failed Embeddings"
@@ -314,6 +457,7 @@ export function Toolbar() {
onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)}
/> />
) : null} ) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div> </div>
</div> </div>
); );
+67
View File
@@ -0,0 +1,67 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
export function UpdateToast() {
const updateStatus = useGalleryStore((s) => s.updateStatus);
const updateVersion = useGalleryStore((s) => s.updateVersion);
const updateProgress = useGalleryStore((s) => s.updateProgress);
const updateDismissed = useGalleryStore((s) => s.updateDismissed);
const installUpdate = useGalleryStore((s) => s.installUpdate);
const dismissUpdate = useGalleryStore((s) => s.dismissUpdate);
const visible =
!updateDismissed &&
(updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing");
return (
<AnimatePresence>
{visible ? (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
transition={{ duration: 0.18 }}
className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-white/10 bg-gray-900 p-4 shadow-xl"
>
{updateStatus === "available" ? (
<>
<p className="text-sm font-medium text-white">Update available</p>
<p className="mt-1 text-xs text-gray-500">
Phokus v{updateVersion} is ready to download and install.
</p>
<div className="mt-3 flex items-center gap-2">
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => void installUpdate()}
>
Install &amp; restart
</button>
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={dismissUpdate}
>
Later
</button>
</div>
</>
) : (
<>
<p className="text-sm font-medium text-white">
{updateStatus === "installing" ? "Installing update..." : "Downloading update..."}
</p>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-white/10">
<div
className={`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}
/>
</div>
<p className="mt-2 text-xs text-gray-600">The app will restart when it finishes.</p>
</>
)}
</motion.div>
) : null}
</AnimatePresence>
);
}
+448
View File
@@ -0,0 +1,448 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
const CONTROLS_HIDE_DELAY_MS = 2500;
const SEEK_STEP_SECONDS = 5;
const VOLUME_STEP = 0.1;
// Session-wide playback preferences shared across player instances.
let persistedVolume = 1;
let persistedMuted = false;
function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const total = Math.floor(seconds);
const s = total % 60;
const m = Math.floor(total / 60) % 60;
const h = Math.floor(total / 3600);
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return `${m}:${s.toString().padStart(2, "0")}`;
}
interface BufferedRange {
start: number;
end: number;
}
function ControlButton({ onClick, title, active = false, children }: {
onClick: () => void;
title: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<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>
);
}
export function VideoPlayer({ src }: { src: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scrubbingRef = useRef(false);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState<BufferedRange[]>([]);
const [volume, setVolume] = useState(persistedVolume);
const [muted, setMuted] = useState(persistedMuted);
const [playbackRate, setPlaybackRate] = useState(1);
const [loop, setLoop] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const speedMenuOpenRef = useRef(speedMenuOpen);
speedMenuOpenRef.current = speedMenuOpen;
// ── Controls visibility ────────────────────────────────────────────────────
const showControls = useCallback(() => {
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => {
const video = videoRef.current;
if (video && !video.paused && !scrubbingRef.current && !speedMenuOpenRef.current) {
setControlsVisible(false);
}
}, CONTROLS_HIDE_DELAY_MS);
}, []);
useEffect(() => {
return () => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
};
}, []);
// ── Video element wiring ───────────────────────────────────────────────────
useEffect(() => {
const video = videoRef.current;
if (!video) return;
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(() => {});
}, [src]);
const readBuffered = useCallback(() => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;
const ranges: BufferedRange[] = [];
for (let i = 0; i < video.buffered.length; i++) {
ranges.push({
start: video.buffered.start(i) / video.duration,
end: video.buffered.end(i) / video.duration,
});
}
setBuffered(ranges);
}, []);
const togglePlay = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
void video.play().catch(() => {});
} else {
video.pause();
}
showControls();
}, [showControls]);
const seekBy = useCallback(
(deltaSeconds: number) => {
const video = videoRef.current;
if (!video || !Number.isFinite(video.duration)) return;
video.currentTime = Math.min(video.duration, Math.max(0, video.currentTime + deltaSeconds));
showControls();
},
[showControls],
);
const applyVolume = useCallback(
(nextVolume: number, nextMuted?: boolean) => {
const video = videoRef.current;
const clamped = Math.min(1, Math.max(0, nextVolume));
setVolume(clamped);
persistedVolume = clamped;
if (nextMuted !== undefined) {
setMuted(nextMuted);
persistedMuted = nextMuted;
if (video) video.muted = nextMuted;
}
if (video) video.volume = clamped;
showControls();
},
[showControls],
);
const toggleMute = useCallback(() => {
const next = !muted;
setMuted(next);
persistedMuted = next;
const video = videoRef.current;
if (video) video.muted = next;
showControls();
}, [muted, showControls]);
const toggleLoop = useCallback(() => {
setLoop((value) => {
const video = videoRef.current;
if (video) video.loop = !value;
return !value;
});
showControls();
}, [showControls]);
const toggleFullscreen = useCallback(() => {
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void containerRef.current?.requestFullscreen().catch(() => {});
}
showControls();
}, [showControls]);
useEffect(() => {
const onChange = () => setFullscreen(document.fullscreenElement !== null);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const setSpeed = useCallback(
(rate: number) => {
setPlaybackRate(rate);
const video = videoRef.current;
if (video) video.playbackRate = rate;
setSpeedMenuOpen(false);
showControls();
},
[showControls],
);
// ── Scrubber ───────────────────────────────────────────────────────────────
const seekToPointer = useCallback((clientX: number) => {
const video = videoRef.current;
const track = trackRef.current;
if (!video || !track || !Number.isFinite(video.duration) || video.duration <= 0) return;
const bounds = track.getBoundingClientRect();
const fraction = Math.min(1, Math.max(0, (clientX - bounds.left) / bounds.width));
video.currentTime = fraction * video.duration;
setCurrentTime(video.currentTime);
}, []);
const handleTrackPointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
scrubbingRef.current = true;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (!scrubbingRef.current) return;
seekToPointer(event.clientX);
},
[seekToPointer],
);
const handleTrackPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture(event.pointerId);
scrubbingRef.current = false;
}, []);
// ── Keyboard ───────────────────────────────────────────────────────────────
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) {
return;
}
switch (event.key) {
case " ":
event.preventDefault();
togglePlay();
break;
case "m":
case "M":
toggleMute();
break;
case "f":
case "F":
toggleFullscreen();
break;
case "l":
case "L":
toggleLoop();
break;
case "ArrowLeft":
if (event.shiftKey) {
event.preventDefault();
seekBy(-SEEK_STEP_SECONDS);
}
break;
case "ArrowRight":
if (event.shiftKey) {
event.preventDefault();
seekBy(SEEK_STEP_SECONDS);
}
break;
case "ArrowUp":
event.preventDefault();
// Adjusting volume always unmutes, matching the slider behavior.
applyVolume(persistedVolume + VOLUME_STEP, false);
break;
case "ArrowDown":
event.preventDefault();
applyVolume(persistedVolume - VOLUME_STEP, false);
break;
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [togglePlay, toggleMute, toggleFullscreen, toggleLoop, seekBy, applyVolume]);
const playedFraction = duration > 0 ? currentTime / duration : 0;
const effectiveVolume = muted ? 0 : volume;
return (
<div
ref={containerRef}
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls}
onClick={(event) => event.stopPropagation()}
>
<video
ref={videoRef}
src={src}
className="h-full w-full object-contain"
onClick={togglePlay}
onDoubleClick={toggleFullscreen}
onPlay={() => {
setPlaying(true);
showControls();
}}
onPause={() => {
setPlaying(false);
setControlsVisible(true);
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onLoadedMetadata={(event) => {
setDuration(event.currentTarget.duration);
readBuffered();
}}
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
onProgress={readBuffered}
/>
<AnimatePresence>
{controlsVisible ? (
<motion.div
className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-2.5 pt-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Scrubber */}
<div
ref={trackRef}
className="group/track relative flex h-4 cursor-pointer items-center"
onPointerDown={handleTrackPointerDown}
onPointerMove={handleTrackPointerMove}
onPointerUp={handleTrackPointerUp}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/15 transition-[height] group-hover/track:h-1.5">
{buffered.map((range, index) => (
<div
key={index}
className="absolute inset-y-0 bg-white/20"
style={{ left: `${range.start * 100}%`, width: `${(range.end - range.start) * 100}%` }}
/>
))}
<div className="absolute inset-y-0 left-0 bg-white/90" style={{ width: `${playedFraction * 100}%` }} />
</div>
<div
className="pointer-events-none absolute h-3 w-3 -translate-x-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover/track:opacity-100"
style={{ left: `${playedFraction * 100}%` }}
/>
</div>
{/* Control row */}
<div className="mt-1 flex items-center gap-1.5">
<ControlButton onClick={togglePlay} title={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" />
</svg>
) : (
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5.14v13.72L19 12 8 5.14z" />
</svg>
)}
</ControlButton>
<span className="ml-1 text-xs tabular-nums text-gray-300">
{formatTime(currentTime)} <span className="text-gray-500">/ {formatTime(duration)}</span>
</span>
<div className="flex-1" />
{/* Volume */}
<ControlButton onClick={toggleMute} title={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" />
<path strokeLinecap="round" strokeLinejoin="round" d="M17 9l4 6m0-6l-4 6" />
</svg>
) : (
<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" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.536 8.464a5 5 0 010 7.072M18.364 5.636a9 9 0 010 12.728" />
</svg>
)}
</ControlButton>
<input
type="range"
min={0}
max={1}
step={0.02}
value={effectiveVolume}
className="h-1 w-20 cursor-pointer accent-white"
onChange={(event) => applyVolume(parseFloat(event.target.value), false)}
onClick={(event) => event.stopPropagation()}
title="Volume (↑/↓)"
/>
{/* Playback speed */}
<div className="relative">
<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>
{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) => (
<button
key={rate}
className={`flex w-full items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-left text-xs tabular-nums transition-colors ${
rate === playbackRate ? "bg-white/10 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
setSpeed(rate);
}}
>
{rate}×
</button>
))}
</div>
) : null}
</div>
{/* Loop */}
<ControlButton onClick={toggleLoop} title={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)"}>
{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" />
</svg>
) : (
<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="M4 8V4h4M20 8V4h-4M4 16v4h4M20 16v4h-4" />
</svg>
)}
</ControlButton>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,112 @@
import { useEffect } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../../store";
import { StepWelcome } from "./StepWelcome";
import { StepAddLibrary } from "./StepAddLibrary";
import { StepPipeline } from "./StepPipeline";
import { StepGalleryPreview } from "./StepGalleryPreview";
import { StepSearchDemo } from "./StepSearchDemo";
import { StepViews } from "./StepViews";
import { StepAiFeatures } from "./StepAiFeatures";
import { StepUpdates } from "./StepUpdates";
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
{ id: "views", title: "Views", component: () => <StepViews /> },
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
{ id: "updates", title: "Staying current", component: () => <StepUpdates /> },
];
export function OnboardingOverlay() {
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
useEffect(() => {
if (!onboardingOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") completeOnboarding();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onboardingOpen, completeOnboarding]);
if (!onboardingOpen) return null;
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
const isFirst = onboardingStep === 0;
const isLast = onboardingStep >= STEPS.length - 1;
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
<div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
Step {onboardingStep + 1} of {STEPS.length}
</p>
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
</div>
<div className="flex items-center gap-1.5">
{STEPS.map((s, i) => (
<button
key={s.id}
aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${
i === onboardingStep ? "w-5 bg-white/70" : i < onboardingStep ? "w-1.5 bg-white/35" : "w-1.5 bg-white/15"
}`}
onClick={() => setOnboardingStep(i)}
/>
))}
</div>
</div>
{/* Step body */}
<div className="min-h-0 flex-1 overflow-y-auto px-7 py-6">
<AnimatePresence mode="wait">
<motion.div
key={step.id}
initial={{ opacity: 0, x: 14 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -14 }}
transition={{ duration: 0.16 }}
>
{step.component()}
</motion.div>
</AnimatePresence>
</div>
{/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
<button
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
onClick={completeOnboarding}
>
Skip tour
</button>
<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"
onClick={() => setOnboardingStep(onboardingStep - 1)}
disabled={isFirst}
>
Back
</button>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
>
{isLast ? "Finish" : "Next"}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore } from "../../store";
import { FakeTile } from "./fakes";
export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders);
const addFolder = useGalleryStore((s) => s.addFolder);
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const handlePick = async () => {
setAddError(null);
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" });
if (typeof selected !== "string") return;
setAdding(true);
try {
await addFolder(selected);
} catch (error) {
setAddError(error instanceof Error ? error.message : String(error));
} finally {
setAdding(false);
}
};
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
A library is just a folder on disk Phokus watches it and keeps itself in sync as files are
added, renamed, or removed. Nothing is moved or copied.
</p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4">
<div className="min-w-0">
{folders.length > 0 ? (
<>
<p className="text-sm text-white">
{folders.length === 1 ? `${folders[0].name}” added` : `${folders.length} folders in your library`}
</p>
<p className="mt-1 text-xs text-gray-500">
Indexing runs in the background you can add more folders from the sidebar any time.
</p>
</>
) : (
<>
<p className="text-sm text-white">Add your first folder</p>
<p className="mt-1 text-xs text-gray-500">
Pick somewhere with photos or videos. You can add more later from the sidebar.
</p>
</>
)}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90">{addError}</p> : null}
</div>
<button
className="shrink-0 rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25 disabled:cursor-not-allowed disabled:opacity-45"
onClick={() => void handlePick()}
disabled={adding}
>
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"}
</button>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated:
</p>
<div className="mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} />
<FakeTile index={1} favorite />
<FakeTile index={2} duration="0:42" />
<FakeTile index={3} rating={4} />
<FakeTile index={4} loaded={false} />
<FakeTile index={5} loaded={false} />
</div>
</div>
);
}
@@ -0,0 +1,113 @@
import { useEffect } from "react";
import { TaggerModelProgress, useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes";
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
// Prefer the current file's byte fraction (the 1.3 GB model dominates); fall
// back to the coarse step count; null renders an indeterminate bar.
function taggerDownloadFraction(progress: TaggerModelProgress | null): number | null {
if (!progress) return null;
if (progress.downloaded_bytes != null && progress.total_bytes != null && progress.total_bytes > 0) {
return progress.downloaded_bytes / progress.total_bytes;
}
if (progress.total_files > 0) return progress.completed_files / progress.total_files;
return null;
}
export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
const taggerModelError = useGalleryStore((s) => s.taggerModelError);
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
useEffect(() => {
void loadTaggerModelStatus();
}, [loadTaggerModelStatus]);
const taggerReady = taggerModelStatus?.ready ?? false;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus's AI runs entirely on this machine nothing is sent anywhere. Semantic search sets
itself up automatically; AI tagging is optional and only downloads if you want it.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging optional</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
The WD tagger model (~1.3 GB download) labels images so you can search with{" "}
<code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> tags look like:
</p>
<span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-400">
{tag}
</span>
))}
</span>
</div>
<div className="shrink-0">
{taggerReady ? (
<span className="inline-flex rounded-md border border-emerald-400/25 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
Installed
</span>
) : (
<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"
onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing}
>
{taggerModelPreparing ? "Downloading..." : "Download now"}
</button>
)}
</div>
</div>
{taggerModelPreparing ? (
<div className="mt-3">
<FakeProgressBar fraction={taggerDownloadFraction(taggerModelProgress)} />
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px] text-gray-600">
<span className="truncate">{taggerModelProgress?.current_file ?? "Preparing..."}</span>
{taggerModelProgress?.downloaded_bytes != null && taggerModelProgress.total_bytes != null ? (
<span className="shrink-0 tabular-nums">
{formatBytes(taggerModelProgress.downloaded_bytes)} / {formatBytes(taggerModelProgress.total_bytes)}
</span>
) : null}
</div>
</div>
) : null}
{!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90">
Download failed: {taggerModelError}
</p>
) : null}
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity built in</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Powers <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> search,
"find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model
(~580 MB) downloads automatically the first time embeddings run. Nothing to do you'll see it
in the background-tasks bar.
</p>
</div>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
Semantic search and similarity are part of the standard pipeline; AI tagging stays optional and
can be downloaded any time from Settings.
</p>
</div>
);
}
@@ -0,0 +1,61 @@
import { useEffect, useState } from "react";
import { FakeTile, ReplayButton } from "./fakes";
const REVEAL_MS = 280;
// Two rows (cols-4) keeps the explainer text visible without scrolling.
const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [
{},
{ favorite: true },
{},
{ duration: "1:24" },
{ rating: 5 },
{ favorite: true, rating: 3 },
{ duration: "0:09" },
{},
];
const TILE_COUNT = TILE_PROPS.length;
/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully
/// revealed with a replay control.
export function StepGalleryPreview() {
const [revealed, setRevealed] = useState(0);
useEffect(() => {
if (revealed >= TILE_COUNT) return;
const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS);
return () => clearTimeout(timer);
}, [revealed]);
const finished = revealed >= TILE_COUNT;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
The gallery is a virtualized grid it stays fast with hundreds of thousands of items. Tiles
carry your favorites, star ratings, and video durations; hover for filename and quick actions.
</p>
<div className="mt-5 grid grid-cols-4 gap-1.5">
{TILE_PROPS.map((props, i) => (
<FakeTile key={i} index={i} loaded={i < revealed} {...props} />
))}
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation,
zoom, inline tag editing, ratings, and a full video player.
</p>
<p className="pt-2.5">
<span className="text-gray-300">The toolbar</span> filters by type, favorites, or rating, sorts by
date/name/size, and switches grid density.
</p>
</div>
{finished ? <ReplayButton onClick={() => setRevealed(0)} /> : null}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes";
const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const;
const STAGE_TOTAL = 128;
const TICK_MS = 80;
const STEP_PER_TICK = 8;
/// A one-shot fake of the background-tasks bar: each stage drains in order,
/// then it stops at "all done" with a replay control.
export function StepPipeline() {
const [stageIndex, setStageIndex] = useState(0);
const [filled, setFilled] = useState(0);
const finished = stageIndex >= STAGES.length;
useEffect(() => {
if (finished) return;
const timer = setTimeout(() => {
// Read from the closure and call setters directly — nesting one setter
// inside another's updater double-advances under React StrictMode.
if (filled + STEP_PER_TICK < STAGE_TOTAL) {
setFilled(filled + STEP_PER_TICK);
} else {
setStageIndex(stageIndex + 1);
setFilled(0);
}
}, TICK_MS);
return () => clearTimeout(timer);
}, [filled, stageIndex, finished]);
const replay = () => {
setStageIndex(0);
setFilled(0);
};
const remaining = STAGE_TOTAL - filled;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
After indexing, Phokus works through a strict pipeline: thumbnails first, then video metadata,
then visual embeddings (for similarity and semantic search), then AI tags. One stage at a time,
so each runs at full speed.
</p>
<p className="mt-5 text-xs text-gray-500">
While it's working you'll see this bar above the gallery here's a preview:
</p>
{/* Fake BackgroundTasks slim bar */}
<div className="mt-3 rounded-lg border border-white/[0.07] bg-white/[0.02] px-4 py-3">
<div className="flex items-center gap-3">
<span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? (
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-60" />
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
</span>
<span className="text-[13px] font-medium text-white/60">Holiday Photos</span>
<div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => (
<FakeStageTag
key={stage}
label={!finished && i === stageIndex ? `${stage} · ${remaining}` : stage}
state={finished || i < stageIndex ? "done" : i === stageIndex ? "active" : "waiting"}
/>
))}
</div>
<FakeProgressBar fraction={finished ? 1 : filled / STAGE_TOTAL} className="ml-auto w-24" />
</div>
</div>
<div className="mt-6 flex items-start justify-between gap-4">
<div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500">
<p className="pb-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like
the queue picks up where it left off next launch.
</p>
<p className="pt-2.5">
<span className="text-gray-300">Per-folder control.</span> Right-click a folder in the sidebar to
pause its background work, and click the bar itself to expand a detailed per-folder view.
</p>
</div>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { ReplayButton, SEARCH_RESULTS } from "./fakes";
const DEMOS = [
{
query: "beach-day_042.jpg",
mode: "Filename",
description: "Plain text matches file names — the default, instant search. One name, one file.",
results: SEARCH_RESULTS.filename,
},
{
query: "/s golden sunset over water",
mode: "Semantic",
description: "Describe what's in the picture. Visual embeddings find matches even when filenames say nothing.",
results: SEARCH_RESULTS.semantic,
},
{
query: "/t landscape",
mode: "Tags",
description: "Search by AI or manual tags. Autocomplete suggests tags as you type.",
results: SEARCH_RESULTS.tags,
},
] as const;
const TYPE_MS = 55;
const HOLD_MS = 2600;
/// Types each demo query, shows matching results, advances through all three
/// modes once, then stops on the last with a replay control.
export function StepSearchDemo() {
const [demoIndex, setDemoIndex] = useState(0);
const [typed, setTyped] = useState(0);
const [finished, setFinished] = useState(false);
const demo = DEMOS[demoIndex];
const isLastDemo = demoIndex === DEMOS.length - 1;
useEffect(() => {
if (finished) return;
if (typed < demo.query.length) {
const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS);
return () => clearTimeout(timer);
}
// Fully typed: hold, then advance — or finish on the last demo.
const timer = setTimeout(() => {
if (isLastDemo) {
setFinished(true);
} else {
setDemoIndex((i) => i + 1);
setTyped(0);
}
}, HOLD_MS);
return () => clearTimeout(timer);
}, [typed, demo.query.length, isLastDemo, finished]);
const replay = () => {
setDemoIndex(0);
setTyped(0);
setFinished(false);
};
const fullyTyped = typed >= demo.query.length;
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/s</code> searches
by meaning, <code className="rounded bg-white/[0.07] px-1 py-0.5 text-[11px] text-gray-200">/t</code> searches tags.
</p>
{/* Mock search bar */}
<div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-white/[0.04] px-3.5 py-2.5">
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<span className="text-sm text-white">
{demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</span>
<span className="ml-auto shrink-0 rounded-md border border-sky-400/25 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium text-sky-300">
{demo.mode}
</span>
</div>
{/* Results hidden (not greyed) until the query finishes typing, so it
doesn't look like the app is reading the user's mind. Space is held
by the invisible tiles so the layout doesn't jump when they appear.
Keyed by demoIndex so switching demos remounts the row fresh at
opacity-0 instead of fading the new images out from the previous
demo's visible state. */}
<div
key={demoIndex}
className={`mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`}
>
{demo.results.map((src, i) => (
<div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]">
<img src={src} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
<div className="mt-4 flex min-h-10 items-start justify-between gap-4">
<p className="text-xs leading-relaxed text-gray-500">{demo.description}</p>
{finished ? <ReplayButton onClick={replay} /> : null}
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { PhokusMark } from "../PhokusMark";
// Closing step. Introduces the app mark (the aperture in the title bar) and,
// since that same mark doubles as the update indicator, explains how updates
// work in one place. The mini app-window mockup shows the lit mark exactly
// where it appears, so there's nothing abstract to decode.
export function StepUpdates() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus keeps itself up to date it quietly checks for new versions on startup, so you don't
have to go looking for one.
</p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
When an update is ready
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">The mark in the title bar lights up</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
That aperture in the top-left corner is Phokus. When a new version is waiting, its focal point
glows amber click it to update and relaunch. Nothing installs on its own; you're always in
control.
</p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */}
<div className="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-white/[0.08] text-gray-300">
<span className="pointer-events-none absolute left-1/2 top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/30 blur-[3px]" />
<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/55 animate-ping" />
<PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
</div>
<span className="text-xs font-semibold tracking-wide text-gray-300">Phokus</span>
<div className="flex-1" />
<div className="flex items-center gap-3.5 text-gray-600">
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<rect y="4.5" width="10" height="1" rx="0.5" fill="currentColor" />
</svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<rect x="0.5" y="0.5" width="9" height="9" rx="1.5" stroke="currentColor" />
</svg>
<svg width="9" height="9" viewBox="0 0 10 10" fill="none">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
</div>
</div>
{/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30">
<div className="w-14 shrink-0 border-r border-white/[0.05] p-2.5">
<div className="h-1.5 w-full rounded bg-white/[0.07]" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
<div className="mt-2 h-1.5 w-3/4 rounded bg-white/[0.04]" />
</div>
<div className="flex-1 p-2.5">
<div className="h-2 w-20 rounded bg-white/[0.06]" />
<div className="mt-2.5 grid grid-cols-5 gap-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-7 rounded bg-white/[0.04]" />
))}
</div>
</div>
</div>
</div>
</div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">
Prefer to manage it yourself?
</h4>
<div className="mt-1 py-4">
<p className="text-sm text-white">Check any time from Settings</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
Settings General shows your current version with a manual{" "}
<span className="text-gray-300">Check for updates</span> button, so you can update on your own
schedule.
</p>
</div>
<p className="mt-5 text-xs leading-relaxed text-gray-500">
That's the tour. Add folders from the sidebar, and revisit any of this from Settings including
re-running this tour.
</p>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { FakeTile, tileGradient } from "./fakes";
function ViewRow({ title, description, preview }: { title: string; description: string; preview: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm text-white">{title}</p>
<p className="mt-1 max-w-md text-xs leading-relaxed text-gray-500">{description}</p>
</div>
<div className="shrink-0">{preview}</div>
</div>
);
}
function ExplorePreview() {
// Cluster blobs of varying size, like the tag cloud / cluster map.
const blobs = [
{ size: 34, x: 4, y: 10, i: 0 },
{ size: 24, x: 46, y: 0, i: 2 },
{ size: 18, x: 86, y: 22, i: 4 },
{ size: 26, x: 30, y: 36, i: 1 },
{ size: 16, x: 70, y: 44, i: 6 },
];
return (
<div className="relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
{blobs.map((blob, idx) => (
<div
key={idx}
className={`absolute rounded-full bg-gradient-to-br opacity-80 ${tileGradient(blob.i)}`}
style={{ width: blob.size, height: blob.size, left: blob.x, top: blob.y }}
/>
))}
</div>
);
}
function TimelinePreview() {
return (
<div className="flex h-[72px] w-32 flex-col justify-center gap-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-3">
{[2024, 2023].map((year, row) => (
<div key={year} className="flex items-center gap-1.5">
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className={`h-4 w-4 rounded-sm bg-gradient-to-br ${tileGradient(row * 3 + i)}`} />
))}
</div>
))}
</div>
);
}
function DuplicatesPreview() {
return (
<div className="flex h-[72px] w-32 items-center justify-center gap-1.5 rounded-lg border border-white/[0.07] bg-white/[0.02]">
<div className="w-10">
<FakeTile index={3} className="rounded-md" />
</div>
<div className="relative w-10">
<FakeTile index={3} className="rounded-md" />
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500/90 px-1 text-[8px] font-semibold text-black">2×</span>
</div>
</div>
);
}
export function StepViews() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Beyond the main grid, the sidebar switches between three more ways to look at your library:
</p>
<div className="mt-3 divide-y divide-white/[0.05]">
<ViewRow
title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
preview={<ExplorePreview />}
/>
<ViewRow
title="Timeline"
description="Everything grouped chronologically by capture date (EXIF), from years down to days."
preview={<TimelinePreview />}
/>
<ViewRow
title="Duplicates"
description="A three-phase exact-duplicate scan with bulk delete — reclaim space from copies safely."
preview={<DuplicatesPreview />}
/>
</div>
<p className="mt-4 text-xs leading-relaxed text-gray-500">
Each view can be scoped to a single folder from its header no need to bounce through the sidebar.
</p>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes";
export function FfmpegStatusRow() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
const ffmpegError = useGalleryStore((s) => s.ffmpegError);
const retryFfmpegDownload = useGalleryStore((s) => s.retryFfmpegDownload);
if (ffmpegStatus === "installed") {
return (
<div className="flex items-center gap-2.5 py-3">
<svg className="h-4 w-4 shrink-0 text-emerald-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p className="text-sm text-white">Media engine ready</p>
<p className="text-xs text-gray-500">FFmpeg is installed video thumbnails and metadata are available.</p>
</div>
</div>
);
}
if (ffmpegStatus === "error") {
return (
<div className="py-3">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p>
<p className="mt-1 text-xs leading-relaxed text-amber-300/90">{ffmpegError}</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500">
You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds.
</p>
</div>
<button
className="shrink-0 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"
onClick={() => void retryFfmpegDownload()}
>
Retry download
</button>
</div>
</div>
);
}
const fraction =
ffmpegStatus === "downloading" && ffmpegProgress && ffmpegProgress.total_bytes > 0
? ffmpegProgress.downloaded_bytes / ffmpegProgress.total_bytes
: null;
return (
<div className="py-3">
<div className="flex items-baseline justify-between gap-6">
<p className="text-sm text-white">
{ffmpegStatus === "unpacking" ? "Unpacking media engine..." : "Downloading media engine (FFmpeg)..."}
</p>
{ffmpegProgress ? (
<span className="text-[11px] tabular-nums text-gray-500">
{formatBytes(ffmpegProgress.downloaded_bytes)} / {formatBytes(ffmpegProgress.total_bytes)}
</span>
) : null}
</div>
<FakeProgressBar fraction={ffmpegStatus === "unpacking" ? null : fraction} className="mt-2.5" />
<p className="mt-2 text-xs leading-relaxed text-gray-500">
FFmpeg powers video thumbnails and metadata. It downloads once in the background you can keep
using the tour and the app while it finishes.
</p>
</div>
);
}
export function StepWelcome() {
return (
<div>
<p className="text-sm leading-relaxed text-gray-300">
Phokus is a local-first media library: point it at your folders and it builds a fast, searchable
gallery with thumbnails, AI tags, and visual search. Everything is processed on this machine
your photos and videos never leave it.
</p>
<p className="mt-2.5 text-sm leading-relaxed text-gray-500">
This short tour shows what to expect. Every step is skippable, and you can re-run it any time
from Settings.
</p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4>
<div className="mt-1 divide-y divide-white/[0.05]">
<FfmpegStatusRow />
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
// Shared placeholder primitives for the onboarding tour. Everything here is
// deliberately fake — rights-clean demo stills (generated, owned), looping
// demo progress — so new users see the real UI's shapes before their own
// library exists.
import sunset from "../../assets/onboarding/sunset.webp";
import sunsetcoast from "../../assets/onboarding/sunsetcoast.webp";
import sunsetlake from "../../assets/onboarding/sunsetlake.webp";
import beach from "../../assets/onboarding/beach.webp";
import landscape1 from "../../assets/onboarding/landscape1.webp";
import landscape2 from "../../assets/onboarding/landscape2.webp";
import forest from "../../assets/onboarding/forest.webp";
import citynight from "../../assets/onboarding/citynight.webp";
import flower from "../../assets/onboarding/flower.webp";
import alpinelake from "../../assets/onboarding/alpinelake.webp";
import dunes from "../../assets/onboarding/dunes.webp";
import cozy from "../../assets/onboarding/cozy.webp";
import cat from "../../assets/onboarding/cat.webp";
import balloon from "../../assets/onboarding/balloon.webp";
import architecture from "../../assets/onboarding/architecture.webp";
// Ordered for grid variety: adjacent indices are visually distinct.
const FAKE_IMAGES = [
sunset,
cozy,
landscape1,
citynight,
flower,
dunes,
alpinelake,
cat,
forest,
balloon,
landscape2,
beach,
architecture,
];
export function fakeImage(index: number): string {
return FAKE_IMAGES[index % FAKE_IMAGES.length];
}
// Result sets matched to what each query would genuinely return.
// - filename: one exact file (a filename search hits a specific name)
// - semantic "golden sunset over water": only the ocean-sunset stills
// - tags "landscape": mountain valleys + the alpine lake (all landscapes)
export const SEARCH_RESULTS = {
filename: [beach],
semantic: [sunsetcoast, sunset, sunsetlake],
tags: [landscape1, landscape2, alpinelake],
} as const;
// Abstract gradient blobs/ticks for the Explore & Timeline mini-previews,
// where small non-photographic shapes read more clearly than tiny stills.
const TILE_GRADIENTS = [
"from-sky-900/70 via-slate-800 to-slate-900",
"from-amber-900/60 via-stone-800 to-stone-900",
"from-emerald-900/60 via-slate-800 to-gray-900",
"from-rose-900/50 via-slate-800 to-slate-900",
"from-indigo-900/60 via-slate-800 to-gray-900",
"from-cyan-900/60 via-slate-800 to-slate-900",
"from-fuchsia-900/40 via-slate-800 to-gray-900",
"from-orange-900/50 via-stone-800 to-stone-900",
];
export function tileGradient(index: number): string {
return TILE_GRADIENTS[index % TILE_GRADIENTS.length];
}
export function FakeTile({
index,
loaded = true,
favorite = false,
rating = 0,
duration,
className = "",
}: {
index: number;
loaded?: boolean;
favorite?: boolean;
rating?: number;
duration?: string;
className?: string;
}) {
return (
<div className={`group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
{loaded ? (
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="absolute inset-0 animate-pulse bg-white/[0.04]" />
)}
{loaded && favorite ? (
<div className="absolute right-1.5 top-1.5 rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z" />
</svg>
</div>
) : null}
{loaded && rating > 0 ? (
<div className="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 rounded-md bg-black/60 px-1.5 py-1 text-amber-300">
{Array.from({ length: rating }).map((_, i) => (
<svg key={i} className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.563.563 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
</svg>
))}
</div>
) : null}
{loaded && duration ? (
<div className="absolute bottom-1.5 right-1.5 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white/80">
{duration}
</div>
) : null}
</div>
);
}
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
const className =
state === "active"
? "bg-white/5 text-gray-300"
: state === "done"
? "bg-emerald-500/10 text-emerald-400"
: "bg-white/4 text-gray-600";
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>;
}
export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
return (
<div className={`h-px overflow-hidden rounded-full bg-white/8 ${className}`}>
{fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" />
) : (
<div
className="h-full bg-blue-500 transition-[width] duration-300"
style={{ width: `${Math.round(Math.min(fraction, 1) * 100)}%` }}
/>
)}
</div>
);
}
export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12a7.5 7.5 0 0012.9 5.3M19.5 12A7.5 7.5 0 006.6 6.7M4.5 6.5v3h3M19.5 17.5v-3h-3" />
</svg>
{label}
</button>
);
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
+31
View File
@@ -0,0 +1,31 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from "@tauri-apps/plugin-notification";
let permissionPromise: Promise<boolean> | null = null;
export function initializeNotifications(): Promise<boolean> {
permissionPromise ??= (async () => {
try {
if (await isPermissionGranted()) return true;
return (await requestPermission()) === "granted";
} catch (error) {
console.warn("Windows notifications are unavailable:", error);
return false;
}
})();
return permissionPromise;
}
export async function notifyTaskComplete(title: string, body: string): Promise<void> {
if (!(await initializeNotifications())) return;
try {
sendNotification({ title, body });
} catch (error) {
console.warn("Could not send task completion notification:", error);
}
}
+1715 -38
View File
File diff suppressed because it is too large Load Diff