52 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Screenshots are transcoded to AVIF/WebP at build via vite-imagetools (sharp);
~5.3MB of masters -> ~0.5MB served. Commits only the 7 in-use captures.
2026-06-15 19:39:56 +01:00
LyAhn 584a92b7cd ci: report GitHub Actions status to Gitea
github/actions/ci GitHub Actions CI finished: failure
2026-06-15 00:36:25 +01:00
LyAhn 6a5cf0afe3 docs(changelog): date the 0.1.0 release (2026-06-14)
CI / check (push) Has been cancelled
2026-06-14 20:28:22 +01:00
LyAhn ce804f5aa5 chore(gitignore): drop blanket *.json ignore, track configs directly
CI / check (push) Has been cancelled
The global *.json rule (added for transient ComfyUI workflow dumps) forced
git add -f on real configs and hid tauri.conf.json from tauri-action's
gitignore-aware globbing. Removed it and the negation workarounds; local-only
ignores (e.g. skills-lock.json) live in .git/info/exclude instead.
2026-06-14 20:10:18 +01:00
82 changed files with 8727 additions and 680 deletions
+20
View File
@@ -0,0 +1,20 @@
# External CI
CI and release builds run on the GitHub mirror because they require Windows
runner capacity. The GitHub workflows report their state back to this Gitea
repository through the commit status API.
Keep this directory in the repository. Gitea checks `.gitea/workflows` before
falling back to `.github/workflows`; an existing directory with no workflow
files prevents the GitHub-only workflows from being queued by Gitea Actions.
## Setup
1. In Gitea, create an access token with `write:repository` permission for an
account that can update `JezzWTF/phokus`.
2. In the GitHub repository, add that token as the Actions repository secret
`GITEA_STATUS_TOKEN`.
The status steps are non-blocking. If Gitea is temporarily unavailable, the
GitHub build result is preserved and the failed status update remains visible
in the workflow log.
+62
View File
@@ -3,7 +3,13 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
paths:
- 'src/**'
- 'src-tauri/**'
pull_request: pull_request:
paths:
- 'src/**'
- 'src-tauri/**'
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
@@ -15,7 +21,33 @@ jobs:
# windows-latest to match the release target — the ML crates (ort, candle) # windows-latest to match the release target — the ML crates (ort, candle)
# and the NSIS bundle only ever ship from Windows. # and the NSIS bundle only ever ship from Windows.
runs-on: windows-latest runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps: steps:
- name: Report pending status to Gitea
if: github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/ci"
description = "GitHub Actions CI is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -52,3 +84,33 @@ jobs:
- name: Clippy - name: Clippy
working-directory: src-tauri working-directory: src-tauri
run: cargo clippy --all-targets --locked --no-default-features -- -D warnings run: cargo clippy --all-targets --locked --no-default-features -- -D warnings
- name: Report final status to Gitea
if: always() && github.event_name != 'pull_request' && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/ci"
description = "GitHub Actions CI finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
+56
View File
@@ -14,7 +14,33 @@ jobs:
permissions: permissions:
contents: write contents: write
runs-on: windows-latest runs-on: windows-latest
env:
GITEA_STATUS_TOKEN: ${{ secrets.GITEA_STATUS_TOKEN }}
steps: steps:
- name: Report pending status to Gitea
if: env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = "pending"
context = "github/actions/release"
description = "GitHub Actions release is running"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
@@ -55,3 +81,33 @@ jobs:
# Cargo args after `--`: ship the CPU/DirectML build — default # Cargo args after `--`: ship the CPU/DirectML build — default
# features enable candle-cuda, which runners (and most users) lack. # features enable candle-cuda, which runners (and most users) lack.
args: '-- --no-default-features' args: '-- --no-default-features'
- name: Report final status to Gitea
if: always() && env.GITEA_STATUS_TOKEN != ''
continue-on-error: true
shell: pwsh
env:
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
JOB_STATUS: ${{ job.status }}
run: |
$state = switch ($env:JOB_STATUS) {
"success" { "success" }
"failure" { "failure" }
default { "error" }
}
$headers = @{
Authorization = "token $env:GITEA_STATUS_TOKEN"
Accept = "application/json"
}
$body = @{
state = $state
context = "github/actions/release"
description = "GitHub Actions release finished: $env:JOB_STATUS"
target_url = $env:GITHUB_RUN_URL
} | ConvertTo-Json
Invoke-RestMethod `
-Method Post `
-Uri "https://git.jezz.wtf/api/v1/repos/JezzWTF/phokus/statuses/$env:GITHUB_SHA" `
-Headers $headers `
-ContentType "application/json" `
-Body $body
-9
View File
@@ -32,17 +32,8 @@ dist-ssr
# Misc # Misc
*.py *.py
*.json
*.pyc *.pyc
# Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit # Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit
# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant". # locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant".
src-tauri/cuda-redist/ 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
+165 -1
View File
@@ -5,7 +5,170 @@ All notable changes to Phokus are documented here. The format is based on
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(0.x: anything may change between minor versions). (0.x: anything may change between minor versions).
## [0.1.0] — Unreleased ## [Unreleased]
### Added
- **Albums** — curate your own collections. A new Albums section in the sidebar
(with cover thumbnails, kept visually distinct from Libraries) lets you create,
rename, reorder (drag the row), and open albums; albums can span multiple
folders. Add images from the gallery's bulk action bar or from the lightbox,
remove them from within an album, and use Manage mode to multi-select and
delete albums in one go. Deleting an album never touches your files — only the
grouping is removed.
- **Multi-select & bulk actions in the gallery** — hover a thumbnail's top-left
corner to reveal a selection checkbox (click it to start selecting); while
selecting, click tiles to toggle and double-click to open. A floating action
bar then lets you tag (with autocomplete), rate, favorite, add to an album, or
delete the whole selection at once. Works in similar-image, region, and album
views too.
- **Colour search** — filter the gallery (and Timeline) by dominant colour via a
collapsible swatch palette plus a custom colour picker in the toolbar; existing
libraries are backfilled automatically in the background.
- **Album-scoped similar search** — when finding visually similar images or
searching by a selected region from an album, you can now keep results scoped
to that album, switch back to the source folder, or search all media.
- **Tag management** — Explore → Tag Cloud gains a Manage mode with a flat tag
list where you can rename a tag, merge it into another (rename it to an
existing tag's name), or delete it from every image. Changes apply across the
whole library.
- **Camera info in the lightbox** — the image info panel now shows EXIF details
(camera, lens, aperture, shutter, ISO, focal length) and, when a photo is
geotagged, its GPS coordinates as a link that opens the location in your
browser. Read on demand from the file, so it works on already-indexed images
without re-indexing.
- **What's New** — after updating, Phokus now greets you with a "What's new"
toast that opens an in-app release-notes screen for the new version, with the
changes grouped into collapsible Added / Changed / Fixed sections.
- **Build badge in Settings** — the version line in Settings → Updates now shows
whether the running build is the CPU or CUDA (GPU-accelerated) variant.
### Changed
- **Safer deletion** — deleting media now asks for confirmation and spells out
that it permanently removes the file(s) from disk. This covers the new gallery
bulk delete and the Duplicate Finder, which previously deleted on a single
click with no confirmation or warning.
- The updater now shows a real download progress bar with a percentage in
Settings → Updates (previously it only said "Downloading").
- **Smaller-screen layout** — the toolbar adapts on narrow windows: the filter
chips scroll horizontally instead of squashing (so "Similar: Folder/All" no
longer stack), the search box shrinks, and the colour-search swatches now open
in a compact popover rather than expanding inline and running off-screen. The
sidebar and the lightbox info panel also narrow to give the gallery more room.
- **Explore cluster layout** — clusters are now sized by image count (busier
clusters are larger and stack on top) and repositioned to avoid overlapping, so
every cluster stays viewable and clickable even in dense libraries.
### Fixed
- **Rating no longer scrambles search results** — rating or favoriting an image
while viewing similar-image, region, semantic, tag, or album results no longer
re-sorts the view back into the default order; the current result ordering is
preserved.
- The update download/install progress toast now reappears when you start an
update from the title-bar indicator or Settings after dismissing the earlier
"Update available" prompt — previously progress only showed in Settings.
- Subtle Light theme — fixed several surfaces and buttons that stayed dark or
became unreadable on hover, including new dialogs and the green action buttons
across the updater and onboarding.
- **Endless self-indexing loop** — adding a folder that contains Phokus's own
app-data directory (for example, your whole user profile) no longer makes the
app index its own thumbnail cache, generate thumbnails of those thumbnails, and
loop forever. That directory is now skipped by both the folder scanner and the
filesystem watcher.
- **Background tasks now lead with the active folder** — when one folder's
workers are paused while another is actively processing, the active folder
takes the main slot in the background-tasks bar instead of the paused one.
- **Oversized window on smaller screens** — on a fresh install the window opened
at a fixed size that was taller than the usable area on displays like
1366×768, leaving part of it off-screen. It now clamps to the monitor's work
area (excluding the taskbar) and centres there when it would overflow; larger
displays and any remembered size/position are left untouched.
- **Explore readability in Subtle Light** — fixed washed-out and unreadable
elements in the Explore view on the light theme. Cluster cards now use a soft
dark caption scrim (the previous cream overlay fogged the photos), with a
legible label, count, and "Open" button; Tag Cloud words use darker accents and
a higher opacity floor instead of near-invisible pale text.
- **Explore polish** — the Tag Cloud now uses the in-app tooltip instead of the
native browser one (and reads "1 image", not "1 images"), and the folder-scope
dropdown no longer opens behind the cluster cards.
## [0.1.1] — 2026-06-23
### Added
- **Custom multi-folder picker** — replaces the native OS dialog with an
in-app folder browser that lets you navigate, stage folders from multiple
locations, and add them all in one go. Duplicate roots are skipped
automatically; partially-failed batches remove successfully-added entries
from the staging panel so only failed folders remain to retry.
- **Rebuild semantic index** maintenance action in Settings — drops and
recreates the vector tables at the current model dimension, then re-queues
every image for embedding. Fixes "dimension mismatch" search errors that
occur after switching between CLIP models with different output sizes.
- **Video playback settings** — new Video Playback group in Settings with two
persisted toggles: "Autoplay in lightbox" (default on) and "Start muted"
(default off). Settings apply to the next opened video rather than the
current one.
- **Timeline scrubber** — a year/month rail on the Timeline view that jumps to
any period in the library. Timeline now loads the full filtered set so the
scrubber spans the whole library instead of just the first page.
- **Folder reordering** in the sidebar — drag-and-drop (with edge auto-scroll)
or keyboard (↑/↓ on the drag handle), with the custom order persisted across
sessions; the Libraries list also gains AZ / ZA / Custom sort.
- Failed AI-tagging jobs can now be located from the background worker prompt,
including a gallery filter for images with failed tags and an expanded list
of failed filenames/errors.
- A new theme system adds Phokus, Subtle Light, and Conventional Dark chrome
options across the app.
- First-run onboarding now includes an inline theme picker so new users can
choose their preferred app chrome before continuing the tour.
### Changed
- Settings sections are reordered — General is now the first and default
section instead of AI Workspace.
- The Duplicate Finder group list is now virtualised — only on-screen cards
mount, so large result sets (e.g. 5,000+ pairs) scroll without lag rather
than mounting every thumbnail at once.
- The gallery grid is now row-virtualised, so very large libraries scroll
smoothly and only on-screen thumbnails are rendered.
- Polished the new theme surfaces before release, including readable
subtle-light secondary buttons, failed-worker action buttons, and onboarding
controls.
- Onboarding preview media keeps the dark gallery/media surface regardless of
the active chrome theme.
### Fixed
- **AVIF thumbnails** — AVIF files are now processed correctly by routing
thumbnail generation through the bundled FFmpeg path instead of the Rust
image decoder (which has no dav1d dependency). Previously-failed AVIF jobs
are requeued on startup; JPEG derivatives are fed to the embedding and
tagging pipeline while the lightbox continues to display the original file.
- Accent text is now readable in the Subtle Light theme.
- Folder picker chevron tooltip now correctly shows "No subfolders" for leaf
entries instead of "Open folder" in both branches.
- Folder picker Unix breadcrumb root now shows "/" instead of always "Home"
for non-home paths such as `/mnt/data`.
- Video embedding jobs are no longer claimed before their thumbnail exists, and
any that previously failed for that reason are requeued on startup — videos no
longer churn through failed embeddings.
- Subtle Light theme consistency — the lightbox metadata panel now follows the
light chrome while the image canvas stays dark (matching Conventional Dark),
and gallery/timeline media badges, duplicate-finder thumbnails, and the window
restore icon now theme correctly instead of staying Phokus-dark.
- Timeline scrolling is now smooth on large libraries — it virtualizes per row
of tiles instead of per month, so a month with thousands of photos no longer
mounts every tile at once (thumbnails now load in incrementally as you scroll,
matching the All Media grid).
- Background worker updates (thumbnails, metadata, embeddings, tags) no longer
re-sort the entire loaded image set on every batch. In Timeline, which loads
the whole library, this re-sort caused severe lag and could crash the app
during background indexing.
## [0.1.0] — 2026-06-14
First public release. Windows desktop, distributed as an unsigned NSIS First public release. Windows desktop, distributed as an unsigned NSIS
installer with a built-in updater. installer with a built-in updater.
@@ -47,4 +210,5 @@ installer with a built-in updater.
Settings, with live size/reclaimable stats. Settings, with live size/reclaimable stats.
- **Window state** persistence and single-instance handling. - **Window state** persistence and single-instance handling.
[0.1.1]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.1
[0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0 [0.1.0]: https://github.com/JezzWTF/phokus/releases/tag/v0.1.0
+5 -2
View File
@@ -15,8 +15,11 @@ pnpm dev:app
# Frontend only (no Tauri window) # Frontend only (no Tauri window)
pnpm dev:vite pnpm dev:vite
# Production build # Production build (CPU)
pnpm build:app pnpm build:app:cpu
# Production build (CUDA / GPU-accelerated)
pnpm build:app:cuda
# Type-check frontend # Type-check frontend
pnpm build:vite pnpm build:vite
+6 -3
View File
@@ -34,7 +34,7 @@ A local-first desktop media library for browsing, filtering, and curating image
| Images | Videos | | Images | Videos |
|--------|--------| |--------|--------|
| jpg, jpeg, png, gif, bmp | mp4, mov, m4v | | jpg, jpeg, png, gif, bmp | mp4, mov, m4v |
| tiff, tif, webp, avif, heic, heif | webm | | tiff, tif, webp, avif | webm |
## Installation ## Installation
@@ -109,8 +109,11 @@ pnpm dev:app
# Frontend only # Frontend only
pnpm dev:vite pnpm dev:vite
# Production build # Production build (CPU)
pnpm build:app pnpm build:app:cpu
# Production build (CUDA / GPU-accelerated)
pnpm build:app:cuda
# Type-check the frontend # Type-check the frontend
pnpm build:vite pnpm build:vite
+64
View File
@@ -0,0 +1,64 @@
# Phokus v0.1.1
> Draft for the GitHub Release body. Fill in the checksums and trim as needed.
**Phokus is a local-first desktop media library for Windows** — point it at
your image and video folders and it builds a fast, searchable gallery with
thumbnails, semantic search, visual discovery, AI tagging, and duplicate
cleanup. Everything is processed on your machine; nothing is uploaded.
This is a quality-of-life release on top of 0.1.0: a new in-app folder
picker, themes, smoother scrolling on large libraries, and a batch of fixes.
If you're updating from 0.1.0, the built-in updater will fetch this for you.
## Install / update
- **Updating from 0.1.0:** the in-app updater will offer 0.1.1 on launch — one
click downloads, installs, and relaunches.
- **Fresh install:** download `Phokus_0.1.1_x64-setup.exe` below and run it.
**Windows SmartScreen will warn** that the publisher is unrecognized — this
build is **not code-signed**. Click **More info → Run anyway**.
- Requires **Windows 10/11**. WebView2 is fetched automatically if missing.
NVIDIA users wanting GPU embedding speed can use the
`Phokus_0.1.1_x64-cuda-setup.exe` variant instead (larger download; bundles the
CUDA runtime DLLs).
## Highlights
### Added
- **Custom multi-folder picker** — navigate and stage folders from multiple
locations and add them in one go, replacing the native OS dialog.
- **Themes** — Phokus, Subtle Light, and Conventional Dark chrome, with an
inline theme picker in first-run onboarding.
- **Timeline scrubber** — a year/month rail to jump anywhere in the library.
- **Folder reordering** in the sidebar (drag-and-drop or keyboard), persisted,
plus AZ / ZA / Custom sort for the Libraries list.
- **Video playback settings** — autoplay-in-lightbox and start-muted toggles.
- **Rebuild semantic index** maintenance action — fixes "dimension mismatch"
search errors after switching CLIP models.
- Locate and filter images with failed AI-tagging jobs.
### Changed
- Gallery grid and Duplicate Finder list are now row-virtualised — large
libraries and large result sets (5,000+ pairs) scroll without lag.
- Settings now open on General by default.
### Fixed
- **AVIF thumbnails** now generate correctly (routed through bundled FFmpeg);
previously-failed AVIF jobs are requeued on startup.
- Video embedding jobs no longer churn through failed states before their
thumbnail exists; previously-failed ones are requeued.
- Timeline scrolling is smooth on large libraries (per-row virtualisation);
background batches no longer re-sort the whole loaded set.
- Numerous Subtle Light theme readability/parity fixes.
See the [changelog](https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md)
for the full list.
## Checksums
```
SHA-256 (Phokus_0.1.1_x64-setup.exe) = 1c19cbeb77f38a44149380c42c76b633add65777d317e1f3ff7e45d96d12d287
SHA-256 (Phokus_0.1.1_x64-cuda-setup.exe) = a7337ef5ee0478a785b48acc8012e8fc5c957341161d6103409213ad78eb845f
```
+8 -4
View File
@@ -1,18 +1,22 @@
{ {
"name": "phokus", "name": "phokus",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.1",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:app": "tauri build", "build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"build:vite": "tsc && vite build", "build:vite": "tsc && vite build",
"build:web": "cd website && tsc && vite build",
"changelog:add": "node tools/changelog-add.mjs",
"clean:app": "cd src-tauri && cargo clean", "clean:app": "cd src-tauri && cargo clean",
"dev:app": "tauri dev", "dev:app": "tauri dev",
"dev:app:cpu": "tauri dev -- --no-default-features", "dev:app:cpu": "tauri dev -- --no-default-features",
"build:app:cpu": "tauri build -- --no-default-features",
"build:app:cuda": "tauri build --config src-tauri/tauri.cuda.conf.json",
"dev:vite": "vite", "dev:vite": "vite",
"dev:web": "cd website && pnpm dev",
"format:app": "cd src-tauri && cargo fmt",
"format:check": "cd src-tauri && cargo fmt --check",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
+395
View File
@@ -76,6 +76,49 @@ importers:
specifier: ^7.0.4 specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
website:
dependencies:
'@fontsource-variable/inter':
specifier: 5.2.8
version: 5.2.8
'@fontsource-variable/space-grotesk':
specifier: 5.2.10
version: 5.2.10
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.1.0
version: 19.2.4
react-dom:
specifier: ^19.1.0
version: 19.2.4(react@19.2.4)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
'@types/react':
specifier: ^19.1.8
version: 19.2.14
'@types/react-dom':
specifier: ^19.1.6
version: 19.2.3(@types/react@19.2.14)
'@vitejs/plugin-react':
specifier: ^4.6.0
version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
tailwindcss:
specifier: ^4.2.2
version: 4.2.2
typescript:
specifier: ~5.8.3
version: 5.8.3
vite:
specifier: ^7.0.4
version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
vite-imagetools:
specifier: ^10.0.0
version: 10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))
packages: packages:
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
@@ -161,6 +204,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
'@esbuild/aix-ppc64@0.27.7': '@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -317,6 +363,165 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
'@fontsource-variable/space-grotesk@5.2.10':
resolution: {integrity: sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -336,6 +541,15 @@ packages:
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rollup/pluginutils@5.4.0':
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.60.1': '@rollup/rollup-android-arm-eabi@4.60.1':
resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==}
cpu: [arm] cpu: [arm]
@@ -770,6 +984,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -805,6 +1022,10 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
imagetools-core@9.1.0:
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
engines: {node: '>=20.0.0'}
jiti@2.6.1: jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
@@ -955,6 +1176,15 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
semver@7.8.4:
resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
source-map-js@1.2.1: source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -984,6 +1214,12 @@ packages:
peerDependencies: peerDependencies:
browserslist: '>= 4.21.0' browserslist: '>= 4.21.0'
vite-imagetools@10.0.0:
resolution: {integrity: sha512-+83L32YPU/2BOHWhudO2+9T5HBvb3+0qHoUNN7fb0+XcAoXilx7aE25cDPWU5kBi5Yc750zYCvHxgfyR+tAuMA==}
engines: {node: '>=22.0.0'}
peerDependencies:
vite: '>=7.0.0'
vite@7.3.1: vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -1159,6 +1395,11 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@emnapi/runtime@1.11.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7': '@esbuild/aix-ppc64@0.27.7':
optional: true optional: true
@@ -1237,6 +1478,106 @@ snapshots:
'@esbuild/win32-x64@0.27.7': '@esbuild/win32-x64@0.27.7':
optional: true optional: true
'@fontsource-variable/inter@5.2.8': {}
'@fontsource-variable/space-grotesk@5.2.10': {}
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.11.1
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -1258,6 +1599,14 @@ snapshots:
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/pluginutils@5.4.0(rollup@4.60.1)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.4
optionalDependencies:
rollup: 4.60.1
'@rollup/rollup-android-arm-eabi@4.60.1': '@rollup/rollup-android-arm-eabi@4.60.1':
optional: true optional: true
@@ -1599,6 +1948,8 @@ snapshots:
escalade@3.2.0: {} escalade@3.2.0: {}
estree-walker@2.0.2: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -1619,6 +1970,8 @@ snapshots:
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
imagetools-core@9.1.0: {}
jiti@2.6.1: {} jiti@2.6.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -1750,6 +2103,39 @@ snapshots:
semver@6.3.1: {} semver@6.3.1: {}
semver@7.8.4: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
tailwindcss@4.2.2: {} tailwindcss@4.2.2: {}
@@ -1771,6 +2157,15 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)):
dependencies:
'@rollup/pluginutils': 5.4.0(rollup@4.60.1)
imagetools-core: 9.1.0
sharp: 0.34.5
vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0)
transitivePeerDependencies:
- rollup
vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0):
dependencies: dependencies:
esbuild: 0.27.7 esbuild: 0.27.7
+4
View File
@@ -1,2 +1,6 @@
packages:
- website
allowBuilds: allowBuilds:
esbuild: true esbuild: true
sharp: true
+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

+1 -1
View File
@@ -4595,7 +4595,7 @@ dependencies = [
[[package]] [[package]]
name = "phokus" name = "phokus"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"candle-core", "candle-core",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "phokus" name = "phokus"
version = "0.1.0" version = "0.1.1"
description = "Local-first desktop media library" description = "Local-first desktop media library"
authors = ["JezzWTF"] authors = ["JezzWTF"]
license = "MIT" license = "MIT"
+4 -14
View File
@@ -78,9 +78,10 @@ static ORT_RUNTIME_INIT: Mutex<bool> = Mutex::new(false);
/// knows to drop its cached `FlorenceCaptioner` and reload with the new EP. /// knows to drop its cached `FlorenceCaptioner` and reload with the new EP.
pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false); pub static CAPTION_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CaptionAcceleration { pub enum CaptionAcceleration {
#[default]
Auto, Auto,
Cpu, Cpu,
Directml, Directml,
@@ -96,17 +97,12 @@ impl CaptionAcceleration {
} }
} }
impl Default for CaptionAcceleration { #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
fn default() -> Self {
Self::Auto
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum CaptionDetail { pub enum CaptionDetail {
Short, Short,
Detailed, Detailed,
#[default]
Paragraph, Paragraph,
} }
@@ -136,12 +132,6 @@ impl CaptionDetail {
} }
} }
impl Default for CaptionDetail {
fn default() -> Self {
Self::Paragraph
}
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct CaptionModelStatus { pub struct CaptionModelStatus {
pub model_id: &'static str, pub model_id: &'static str,
+91
View File
@@ -0,0 +1,91 @@
//! Dominant-color palette extraction for color search.
//!
//! Colors are sampled from the already-generated thumbnail (small, fast) rather
//! than the full image. We coarse-quantize pixels into an RGB histogram, then
//! return the most populated bins as representative colors with their weight
//! (fraction of sampled pixels). Search then filters images whose palette has a
//! color within a distance threshold of the query color.
use image::RgbImage;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Copy)]
pub struct PaletteColor {
pub r: u8,
pub g: u8,
pub b: u8,
/// Fraction of sampled pixels (0.01.0) that fell in this color's bin.
pub weight: f32,
}
/// Bits kept per channel when binning. 4 bits → 16 levels/channel → 4096 bins:
/// coarse enough to group near-identical shades, fine enough to separate hues.
const QUANT_BITS: u32 = 4;
/// Cap on sampled pixels so very large frames stay cheap; thumbnails are tiny so
/// this rarely bites, but the backfill may read arbitrary thumbnail sizes.
const MAX_SAMPLES: usize = 50_000;
/// Extract up to `k` dominant colors from an RGB image, most-common first.
pub fn extract_palette(img: &RgbImage, k: usize) -> Vec<PaletteColor> {
let pixels = img.as_raw();
let pixel_count = pixels.len() / 3;
if pixel_count == 0 {
return Vec::new();
}
let step = (pixel_count / MAX_SAMPLES).max(1);
let shift = 8 - QUANT_BITS;
// bin key → (sum_r, sum_g, sum_b, count); summing lets us return the bin's
// average color rather than the quantized corner.
let mut bins: HashMap<u16, (u64, u64, u64, u64)> = HashMap::new();
let mut total: u64 = 0;
for pixel in pixels.chunks_exact(3).step_by(step) {
let (r, g, b) = (pixel[0], pixel[1], pixel[2]);
let key = (((r as u16) >> shift) << (QUANT_BITS * 2))
| (((g as u16) >> shift) << QUANT_BITS)
| ((b as u16) >> shift);
let entry = bins.entry(key).or_insert((0, 0, 0, 0));
entry.0 += r as u64;
entry.1 += g as u64;
entry.2 += b as u64;
entry.3 += 1;
total += 1;
}
if total == 0 {
return Vec::new();
}
let mut entries: Vec<(u64, u64, u64, u64)> = bins.into_values().collect();
entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.3));
entries
.into_iter()
.take(k)
.map(|(sum_r, sum_g, sum_b, count)| PaletteColor {
r: (sum_r / count) as u8,
g: (sum_g / count) as u8,
b: (sum_b / count) as u8,
weight: count as f32 / total as f32,
})
.collect()
}
/// Decode a thumbnail file and extract its palette. Used by the backfill pass.
pub fn extract_palette_from_file(thumbnail_path: &Path, k: usize) -> Option<Vec<PaletteColor>> {
let img = image::ImageReader::open(thumbnail_path)
.ok()?
.decode()
.ok()?;
Some(extract_palette(&img.into_rgb8(), k))
}
/// Number of palette colors stored per image.
pub const PALETTE_SIZE: usize = 5;
/// Max squared RGB distance for a palette color to count as matching a query
/// color (~70 units in RGB space). Tunable feel/precision of color search.
pub const MATCH_DISTANCE_SQ: i64 = 4900;
/// Minimum weight (fraction of pixels) a palette color must have to match, so
/// trivial specks of a color don't trigger a match.
pub const MATCH_MIN_WEIGHT: f64 = 0.05;
+741 -27
View File
@@ -2,14 +2,17 @@ use crate::captioner::{
self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe, self, CaptionAcceleration, CaptionDetail, CaptionModelStatus, CaptionRuntimeProbe,
CaptionVisionProbe, CaptionVisionProbe,
}; };
use crate::db::{self, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag}; use crate::db::{
self, Album, DbPool, ExploreTagEntry, Folder, FolderJobProgress, ImageRecord, ImageTag,
};
use crate::embedder; use crate::embedder;
use crate::hnsw_index; use crate::hnsw_index;
use crate::indexer::{self, WatcherHandle}; use crate::indexer::{self, WatcherHandle};
use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe};
use crate::vector; use crate::vector;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Emitter, Manager, State}; use tauri::{AppHandle, Emitter, Manager, State};
pub type DbState = DbPool; pub type DbState = DbPool;
@@ -38,6 +41,10 @@ pub struct GetImagesParams {
pub favorites_only: Option<bool>, pub favorites_only: Option<bool>,
pub rating_min: Option<i64>, pub rating_min: Option<i64>,
pub embedding_failed_only: Option<bool>, pub embedding_failed_only: Option<bool>,
pub tagging_failed_only: Option<bool>,
/// Optional `[r, g, b]` color filter — matches images whose palette contains
/// a prominent color near this one.
pub color: Option<[u8; 3]>,
pub sort: Option<String>, pub sort: Option<String>,
pub offset: Option<i64>, pub offset: Option<i64>,
pub limit: Option<i64>, pub limit: Option<i64>,
@@ -54,6 +61,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams { pub struct FindSimilarImagesParams {
pub image_id: i64, pub image_id: i64,
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
/// When set, restrict results to images in this album (takes precedence
/// over `folder_id`). Used by the "Similar: Album" scope.
pub album_id: Option<i64>,
pub offset: Option<usize>, pub offset: Option<usize>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub threshold: Option<f32>, pub threshold: Option<f32>,
@@ -68,6 +78,8 @@ pub struct FindSimilarByRegionParams {
pub crop_w: f32, pub crop_w: f32,
pub crop_h: f32, pub crop_h: f32,
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
/// Restrict to an album (takes precedence over `folder_id`).
pub album_id: Option<i64>,
pub offset: Option<usize>, pub offset: Option<usize>,
pub limit: Option<usize>, pub limit: Option<usize>,
} }
@@ -214,19 +226,63 @@ pub struct GetImagesByIdsParams {
pub image_ids: Vec<i64>, pub image_ids: Vec<i64>,
} }
#[tauri::command] #[derive(Serialize)]
pub async fn add_folder( #[serde(tag = "status", content = "data", rename_all = "camelCase")]
pub enum FolderAddResult {
Added(Folder),
Skipped(String),
Error(String),
}
enum AddOutcome {
Added(Folder),
Skipped(Folder),
}
fn paths_match(left: &str, right: &str) -> bool {
let left_path = PathBuf::from(left);
let right_path = PathBuf::from(right);
if let (Ok(left_canonical), Ok(right_canonical)) =
(left_path.canonicalize(), right_path.canonicalize())
{
return left_canonical == right_canonical;
}
#[cfg(windows)]
{
left.trim_end_matches(['\\', '/'])
.eq_ignore_ascii_case(right.trim_end_matches(['\\', '/']))
}
#[cfg(not(windows))]
{
left.trim_end_matches('/').eq(right.trim_end_matches('/'))
}
}
fn add_one_folder(
app: AppHandle, app: AppHandle,
db: State<'_, DbState>, db: DbPool,
watcher: State<'_, WatcherHandle>, watcher: WatcherHandle,
path: String, path: String,
) -> Result<Folder, String> { ) -> Result<AddOutcome, String> {
let folder_path = PathBuf::from(&path); let folder_path = PathBuf::from(&path);
if !folder_path.exists() || !folder_path.is_dir() { if !folder_path.exists() || !folder_path.is_dir() {
return Err("Path is not a valid directory".into()); return Err("Path is not a valid directory".into());
} }
{
let conn = db.get().map_err(|e| e.to_string())?;
if let Some(folder) = db::get_folders(&conn)
.map_err(|e| e.to_string())?
.into_iter()
.find(|folder| paths_match(&folder.path, &path))
{
return Ok(AddOutcome::Skipped(folder));
}
}
// Let the webview load media from this folder via the asset protocol. // Let the webview load media from this folder via the asset protocol.
if let Err(error) = app if let Err(error) = app
.asset_protocol_scope() .asset_protocol_scope()
@@ -256,9 +312,46 @@ pub async fn add_folder(
.ok_or("Folder not found after insert")?; .ok_or("Folder not found after insert")?;
watcher.add_folder(folder_path.clone(), folder_id); watcher.add_folder(folder_path.clone(), folder_id);
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path); indexer::index_folder(app, db, folder_id, folder_path);
Ok(folder) Ok(AddOutcome::Added(folder))
}
#[tauri::command]
pub async fn add_folder(
app: AppHandle,
db: State<'_, DbState>,
watcher: State<'_, WatcherHandle>,
path: String,
) -> Result<Folder, String> {
match add_one_folder(app, db.inner().clone(), watcher.inner().clone(), path)? {
AddOutcome::Added(folder) => Ok(folder),
AddOutcome::Skipped(folder) => Ok(folder),
}
}
#[tauri::command]
pub async fn add_folders(
app: AppHandle,
db: State<'_, DbState>,
watcher: State<'_, WatcherHandle>,
paths: Vec<String>,
) -> Result<Vec<FolderAddResult>, String> {
Ok(paths
.into_iter()
.map(|path| {
match add_one_folder(
app.clone(),
db.inner().clone(),
watcher.inner().clone(),
path,
) {
Ok(AddOutcome::Added(folder)) => FolderAddResult::Added(folder),
Ok(AddOutcome::Skipped(folder)) => FolderAddResult::Skipped(folder.path),
Err(error) => FolderAddResult::Error(error),
}
})
.collect())
} }
#[tauri::command] #[tauri::command]
@@ -267,6 +360,164 @@ pub async fn get_folders(db: State<'_, DbState>) -> Result<Vec<Folder>, String>
db::get_folders(&conn).map_err(|e| e.to_string()) db::get_folders(&conn).map_err(|e| e.to_string())
} }
#[derive(Serialize)]
pub struct DirListing {
pub current: Option<String>,
pub parent: Option<String>,
pub entries: Vec<DirEntry>,
}
#[derive(Serialize)]
pub struct DirEntry {
pub name: String,
pub path: String,
pub has_children: bool,
}
fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn directory_has_children(path: &Path) -> bool {
std::fs::read_dir(path)
.map(|mut entries| {
entries.any(|entry| {
entry
.ok()
.and_then(|entry| entry.file_type().ok())
.map(|file_type| file_type.is_dir())
.unwrap_or(false)
})
})
.unwrap_or(false)
}
fn common_root_candidates() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(windows)]
{
for letter in b'A'..=b'Z' {
let drive = format!("{}:\\", letter as char);
let path = PathBuf::from(&drive);
if path.exists() {
roots.push(path);
}
}
if let Ok(profile) = std::env::var("USERPROFILE") {
let home = PathBuf::from(profile);
roots.push(home.clone());
for child in ["Pictures", "Videos", "Desktop", "Downloads"] {
roots.push(home.join(child));
}
}
}
#[cfg(not(windows))]
{
if let Ok(home) = std::env::var("HOME") {
roots.push(PathBuf::from(home));
}
roots.push(PathBuf::from("/"));
}
roots
}
fn list_roots() -> Vec<DirEntry> {
let mut seen = HashSet::new();
let mut entries: Vec<DirEntry> = common_root_candidates()
.into_iter()
.filter(|path| path.exists() && path.is_dir())
.filter_map(|path| {
let path_string = path_to_string(&path);
if !seen.insert(path_string.clone()) {
return None;
}
let name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| path_string.clone());
Some(DirEntry {
name,
path: path_string,
has_children: directory_has_children(&path),
})
})
.collect();
entries.sort_by_key(|entry| entry.name.to_lowercase());
entries
}
fn list_child_directories(path: &Path) -> Result<Vec<DirEntry>, String> {
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(path).map_err(|e| e.to_string())?;
for entry in read_dir.flatten() {
let file_name = entry.file_name();
let name = file_name.to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let child_path = entry.path();
let is_dir = entry
.file_type()
.map(|file_type| file_type.is_dir())
.unwrap_or(false);
if !is_dir {
continue;
}
entries.push(DirEntry {
name,
path: path_to_string(&child_path),
has_children: directory_has_children(&child_path),
});
}
entries.sort_by_key(|entry| entry.name.to_lowercase());
Ok(entries)
}
#[tauri::command]
pub async fn list_directories(path: Option<String>) -> Result<DirListing, String> {
let trimmed = path.as_deref().map(str::trim).unwrap_or("");
if trimmed.is_empty() {
return Ok(DirListing {
current: None,
parent: None,
entries: list_roots(),
});
}
let current_path = PathBuf::from(trimmed);
let parent = current_path.parent().map(path_to_string);
let entries = list_child_directories(&current_path)?;
Ok(DirListing {
current: Some(path_to_string(&current_path)),
parent,
entries,
})
}
#[derive(Deserialize)]
pub struct ReorderFoldersParams {
pub folder_ids: Vec<i64>,
}
#[tauri::command]
pub async fn reorder_folders(
db: State<'_, DbState>,
params: ReorderFoldersParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_folders(&conn, &params.folder_ids).map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn get_background_job_progress( pub async fn get_background_job_progress(
db: State<'_, DbState>, db: State<'_, DbState>,
@@ -315,6 +566,8 @@ pub async fn get_images(
let favorites_only = params.favorites_only.unwrap_or(false); let favorites_only = params.favorites_only.unwrap_or(false);
let rating_min = params.rating_min.unwrap_or(0); let rating_min = params.rating_min.unwrap_or(0);
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
let color = params.color.map(|[r, g, b]| (r, g, b));
let total = db::count_images( let total = db::count_images(
&conn, &conn,
@@ -324,6 +577,8 @@ pub async fn get_images(
favorites_only, favorites_only,
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only,
color,
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@@ -335,6 +590,8 @@ pub async fn get_images(
favorites_only, favorites_only,
rating_min, rating_min,
embedding_failed_only, embedding_failed_only,
tagging_failed_only,
color,
sort, sort,
offset, offset,
limit, limit,
@@ -458,6 +715,7 @@ pub async fn find_similar_images(
&conn, &conn,
params.image_id, params.image_id,
params.folder_id, params.folder_id,
params.album_id,
threshold, threshold,
offset, offset,
limit + 1, limit + 1,
@@ -503,24 +761,36 @@ pub async fn find_similar_by_region(
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Search for similar images using the crop embedding // Search for similar images using the crop embedding. Album scope takes
let image_ids = match params.folder_id { // precedence over folder scope.
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( let image_ids = if let Some(album_id) = params.album_id {
vector::search_image_ids_by_embedding_in_album(
&conn, &conn,
&embedding, &embedding,
folder_id, album_id,
Some(params.image_id), Some(params.image_id),
offset + limit + 1, offset + limit + 1,
) )
.map_err(|e| e.to_string())?, .map_err(|e| e.to_string())?
None => { } else {
// Fetch one extra candidate to compensate for the source image that match params.folder_id {
// will be removed, so has_more is accurate and results span multiple pages. Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
let mut ids = &conn,
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2) &embedding,
.map_err(|e| e.to_string())?; folder_id,
ids.retain(|&id| id != params.image_id); Some(params.image_id),
ids offset + limit + 1,
)
.map_err(|e| e.to_string())?,
None => {
// Fetch one extra candidate to compensate for the source image that
// will be removed, so has_more is accurate and results span multiple pages.
let mut ids =
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
.map_err(|e| e.to_string())?;
ids.retain(|&id| id != params.image_id);
ids
}
} }
}; };
@@ -580,6 +850,34 @@ pub async fn retry_failed_embeddings(
db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string()) db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string())
} }
#[derive(Serialize)]
pub struct FailedImageItem {
pub image_id: i64,
pub filename: String,
pub path: String,
pub error: Option<String>,
}
#[tauri::command]
pub async fn get_failed_tagging_images(
db: State<'_, DbState>,
folder_id: i64,
) -> Result<Vec<FailedImageItem>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::get_failed_tagging_images(&conn, folder_id)
.map(|rows| {
rows.into_iter()
.map(|(image_id, filename, path, error)| FailedImageItem {
image_id,
filename,
path,
error,
})
.collect()
})
.map_err(|e| e.to_string())
}
#[tauri::command] #[tauri::command]
pub async fn semantic_search_images( pub async fn semantic_search_images(
db: State<'_, DbState>, db: State<'_, DbState>,
@@ -1080,7 +1378,7 @@ pub async fn find_duplicates(
} }
}; };
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1; let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
if done % 100 == 0 || done == total { if done.is_multiple_of(100) || done == total {
let _ = app_hash.emit( let _ = app_hash.emit(
"duplicate_scan_progress", "duplicate_scan_progress",
DuplicateScanProgress { DuplicateScanProgress {
@@ -1141,7 +1439,7 @@ pub async fn find_duplicates(
hash_skipped.fetch_add(1, Ordering::Relaxed); hash_skipped.fetch_add(1, Ordering::Relaxed);
} }
let done = counter.fetch_add(1, Ordering::Relaxed) + 1; let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
if done % 100 == 0 || done == c_total { if done.is_multiple_of(100) || done == c_total {
let _ = app_hash.emit( let _ = app_hash.emit(
"duplicate_scan_progress", "duplicate_scan_progress",
DuplicateScanProgress { DuplicateScanProgress {
@@ -1230,7 +1528,7 @@ pub async fn find_duplicates(
confirm_skipped.fetch_add(1, Ordering::Relaxed); confirm_skipped.fetch_add(1, Ordering::Relaxed);
} }
let done = counter.fetch_add(1, Ordering::Relaxed) + 1; let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
if done % 100 == 0 || done == confirming_total { if done.is_multiple_of(100) || done == confirming_total {
let _ = app_confirm.emit( let _ = app_confirm.emit(
"duplicate_scan_progress", "duplicate_scan_progress",
DuplicateScanProgress { DuplicateScanProgress {
@@ -1283,7 +1581,7 @@ pub async fn find_duplicates(
.collect(); .collect();
// Largest duplicates first — wastes the most space // Largest duplicates first — wastes the most space
groups.sort_by(|a, b| b.file_size.cmp(&a.file_size)); groups.sort_by_key(|group| std::cmp::Reverse(group.file_size));
// Persist results so they survive restart — best-effort, ignore errors. // Persist results so they survive restart — best-effort, ignore errors.
let folder_scope = match folder_id { let folder_scope = match folder_id {
@@ -1375,6 +1673,119 @@ pub async fn get_images_by_ids(
db::get_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string()) db::get_images_by_ids(&conn, &params.image_ids).map_err(|e| e.to_string())
} }
/// Which acceleration variant this binary was compiled with. Used to badge the
/// version in Settings so it's clear whether the CPU or CUDA build is running.
#[tauri::command]
pub fn get_build_variant() -> String {
if cfg!(feature = "candle-cuda") {
"cuda".to_string()
} else {
"cpu".to_string()
}
}
/// Camera/EXIF metadata for the lightbox info panel. Read on demand from the
/// file (not stored in the DB) so it works on every already-indexed image
/// without a reindex. All fields are optional — absent tags are simply omitted.
#[derive(Serialize, Default)]
pub struct ImageExif {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
pub iso: Option<String>,
pub f_number: Option<String>,
pub exposure_time: Option<String>,
pub focal_length: Option<String>,
pub datetime_original: Option<String>,
pub gps_lat: Option<f64>,
pub gps_lon: Option<f64>,
}
fn gps_coord(exif: &exif::Exif, coord: exif::Tag, reference: exif::Tag) -> Option<f64> {
let (max_abs, positive_ref, negative_ref) = match (coord, reference) {
(exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) => (90.0, b'N', b'S'),
(exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) => (180.0, b'E', b'W'),
_ => return None,
};
let field = exif.get_field(coord, exif::In::PRIMARY)?;
if let exif::Value::Rational(ref parts) = field.value {
if parts.len() >= 3 {
let degrees = parts[0].to_f64() + parts[1].to_f64() / 60.0 + parts[2].to_f64() / 3600.0;
if !degrees.is_finite() || degrees < 0.0 || degrees > max_abs {
return None;
}
// Read the hemisphere straight from the ref tag's ASCII bytes
// ("N"/"S"/"E"/"W") rather than its formatted display string.
let ref_byte =
exif.get_field(reference, exif::In::PRIMARY)
.and_then(|f| match &f.value {
exif::Value::Ascii(values) => values.iter().flatten().next().copied(),
_ => None,
})?;
if ref_byte != positive_ref && ref_byte != negative_ref {
return None;
}
let signed = if ref_byte == negative_ref {
-degrees
} else {
degrees
};
return signed
.is_finite()
.then_some(signed.clamp(-max_abs, max_abs));
}
}
None
}
fn extract_image_exif(path: &Path) -> ImageExif {
let mut out = ImageExif::default();
let Ok(file) = std::fs::File::open(path) else {
return out;
};
let mut reader = std::io::BufReader::new(file);
let Ok(exif) = exif::Reader::new().read_from_container(&mut reader) else {
return out;
};
let text = |tag: exif::Tag| -> Option<String> {
let value = exif
.get_field(tag, exif::In::PRIMARY)?
.display_value()
.with_unit(&exif)
.to_string();
let trimmed = value.trim().trim_matches('"').trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
};
out.make = text(exif::Tag::Make);
out.model = text(exif::Tag::Model);
out.lens = text(exif::Tag::LensModel);
out.iso = text(exif::Tag::PhotographicSensitivity);
out.f_number = text(exif::Tag::FNumber);
out.exposure_time = text(exif::Tag::ExposureTime);
out.focal_length = text(exif::Tag::FocalLength);
out.datetime_original = text(exif::Tag::DateTimeOriginal);
out.gps_lat = gps_coord(&exif, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef);
out.gps_lon = gps_coord(&exif, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef);
out
}
#[derive(Deserialize)]
pub struct GetImageExifParams {
pub image_id: i64,
}
#[tauri::command]
pub async fn get_image_exif(
db: State<'_, DbState>,
params: GetImageExifParams,
) -> Result<ImageExif, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let record = db::get_image_by_id(&conn, params.image_id).map_err(|e| e.to_string())?;
Ok(extract_image_exif(Path::new(&record.path)))
}
// ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ── // ── k-means with cosine similarity (all vectors assumed to be unit-normalized) ──
fn dot(a: &[f32], b: &[f32]) -> f32 { fn dot(a: &[f32], b: &[f32]) -> f32 {
@@ -1468,6 +1879,7 @@ fn kmeans_cosine(
pub struct FailedEmbeddingItem { pub struct FailedEmbeddingItem {
pub image_id: i64, pub image_id: i64,
pub filename: String, pub filename: String,
pub path: String,
pub error: Option<String>, pub error: Option<String>,
} }
@@ -1480,9 +1892,10 @@ pub async fn get_failed_embedding_images(
let rows = db::get_failed_embedding_images(&conn, folder_id).map_err(|e| e.to_string())?; let rows = db::get_failed_embedding_images(&conn, folder_id).map_err(|e| e.to_string())?;
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(image_id, filename, error)| FailedEmbeddingItem { .map(|(image_id, filename, path, error)| FailedEmbeddingItem {
image_id, image_id,
filename, filename,
path,
error, error,
}) })
.collect()) .collect())
@@ -1787,6 +2200,228 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu
db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string()) db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string())
} }
#[derive(Deserialize)]
pub struct RenameTagParams {
pub from: String,
pub to: String,
}
#[derive(Deserialize)]
pub struct DeleteTagParams {
pub tag: String,
}
#[tauri::command]
pub async fn rename_tag(db: State<'_, DbState>, params: RenameTagParams) -> Result<(), String> {
let from = params.from.trim();
let to = params.to.trim();
if from.is_empty() || to.is_empty() {
return Err("Tag names cannot be empty".to_string());
}
if from == to {
return Ok(());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::rename_tag(&conn, from, to).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_tag(db: State<'_, DbState>, params: DeleteTagParams) -> Result<i64, String> {
let tag = params.tag.trim();
if tag.is_empty() {
return Err("Tag name cannot be empty".to_string());
}
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_tag(&conn, tag).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Albums
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct CreateAlbumParams {
pub name: String,
}
#[derive(Deserialize)]
pub struct RenameAlbumParams {
pub album_id: i64,
pub new_name: String,
}
#[derive(Deserialize)]
pub struct DeleteAlbumParams {
pub album_id: i64,
}
#[derive(Deserialize)]
pub struct ReorderAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct DeleteAlbumsParams {
pub album_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct AlbumImagesParams {
pub album_id: i64,
pub image_ids: Vec<i64>,
}
#[derive(Deserialize)]
pub struct GetAlbumImagesParams {
pub album_id: i64,
pub sort: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[tauri::command]
pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::list_albums(&conn).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_album(
db: State<'_, DbState>,
params: CreateAlbumParams,
) -> Result<Album, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::create_album(&conn, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn rename_album(db: State<'_, DbState>, params: RenameAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
let name = params.new_name.trim();
if name.is_empty() {
return Err("Album name cannot be empty".to_string());
}
db::rename_album(&conn, params.album_id, name).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_album(&conn, params.album_id).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn reorder_albums(
db: State<'_, DbState>,
params: ReorderAlbumsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::reorder_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn delete_albums(
db: State<'_, DbState>,
params: DeleteAlbumsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::delete_albums(&conn, &params.album_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn add_images_to_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<i64, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::add_images_to_album(&conn, params.album_id, &params.image_ids).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn remove_images_from_album(
db: State<'_, DbState>,
params: AlbumImagesParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::remove_images_from_album(&conn, params.album_id, &params.image_ids)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_album_images(
db: State<'_, DbState>,
params: GetAlbumImagesParams,
) -> Result<ImagesPage, String> {
let conn = db.get().map_err(|e| e.to_string())?;
let sort = params.sort.as_deref().unwrap_or("position");
let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(100);
let total = db::count_album_images(&conn, params.album_id).map_err(|e| e.to_string())?;
let images = db::get_album_images(&conn, params.album_id, sort, offset, limit)
.map_err(|e| e.to_string())?;
Ok(ImagesPage {
images,
total,
offset,
limit,
})
}
// ---------------------------------------------------------------------------
// Bulk image operations
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct BulkUpdateDetailsParams {
pub image_ids: Vec<i64>,
pub favorite: Option<bool>,
pub rating: Option<i64>,
}
#[derive(Deserialize)]
pub struct BulkAddTagsParams {
pub image_ids: Vec<i64>,
pub tags: Vec<String>,
}
#[derive(Deserialize)]
pub struct BulkRemoveTagParams {
pub image_ids: Vec<i64>,
pub tag: String,
}
#[tauri::command]
pub async fn bulk_update_details(
db: State<'_, DbState>,
params: BulkUpdateDetailsParams,
) -> Result<Vec<ImageRecord>, String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_update_details(&conn, &params.image_ids, params.favorite, params.rating)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_add_tags(
db: State<'_, DbState>,
params: BulkAddTagsParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_add_tags(&conn, &params.image_ids, &params.tags).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn bulk_remove_tag(
db: State<'_, DbState>,
params: BulkRemoveTagParams,
) -> Result<(), String> {
let conn = db.get().map_err(|e| e.to_string())?;
db::bulk_remove_tag_by_name(&conn, &params.image_ids, &params.tag).map_err(|e| e.to_string())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Queue scope / folder-id persistence // Queue scope / folder-id persistence
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1877,6 +2512,47 @@ pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> {
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
#[derive(Deserialize)]
pub struct OpenMapLocationParams {
pub lat: f64,
pub lon: f64,
}
#[tauri::command]
pub async fn open_map_location(
app: AppHandle,
params: OpenMapLocationParams,
) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
if !params.lat.is_finite()
|| !params.lon.is_finite()
|| !(-90.0..=90.0).contains(&params.lat)
|| !(-180.0..=180.0).contains(&params.lon)
{
return Err("Invalid map coordinates".to_string());
}
let url = format!(
"https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=15/{lat:.6}/{lon:.6}",
lat = params.lat,
lon = params.lon,
);
app.opener()
.open_url(url, None::<&str>)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn open_changelog_url(app: AppHandle) -> Result<(), String> {
use tauri_plugin_opener::OpenerExt;
app.opener()
.open_url(
"https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md",
None::<&str>,
)
.map_err(|e| e.to_string())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Database maintenance // Database maintenance
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1939,6 +2615,14 @@ pub async fn vacuum_database(
}) })
} }
#[tauri::command]
pub async fn rebuild_semantic_index(db: State<'_, DbState>) -> Result<usize, String> {
let conn = db.get().map_err(|e| e.to_string())?;
// Serialize against the embedding worker (which writes under the same lock)
// so the rebuild can't interleave with an in-flight embedding batch.
indexer::with_db_write_lock(|| db::reset_all_embeddings(&conn)).map_err(|e| e.to_string())
}
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct OrphanedThumbnailsInfo { pub struct OrphanedThumbnailsInfo {
pub count: u64, pub count: u64,
@@ -2148,3 +2832,33 @@ pub async fn set_onboarding_completed(app: AppHandle, completed: bool) -> Result
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
const LAST_SEEN_VERSION_FILE: &str = "settings/last_seen_version.txt";
/// The app version recorded on the previous launch. `None` when the file is
/// absent (fresh install, or first launch since this was introduced) — the
/// frontend uses that to decide whether to surface the "What's New" prompt.
#[tauri::command]
pub async fn get_last_seen_version(app: AppHandle) -> Result<Option<String>, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let path = app_dir.join(LAST_SEEN_VERSION_FILE);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let trimmed = content.trim();
Ok(if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
})
}
#[tauri::command]
pub async fn set_last_seen_version(app: AppHandle, version: String) -> Result<(), String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
let settings_dir = app_dir.join("settings");
std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?;
std::fs::write(settings_dir.join("last_seen_version.txt"), version.trim())
.map_err(|e| e.to_string())
}
+597 -15
View File
@@ -31,6 +31,19 @@ pub struct Folder {
pub image_count: i64, pub image_count: i64,
pub indexed_at: Option<String>, pub indexed_at: Option<String>,
pub scan_error: Option<String>, pub scan_error: Option<String>,
pub sort_order: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Album {
pub id: i64,
pub name: String,
pub cover_image_id: Option<i64>,
pub cover_thumbnail_path: Option<String>,
pub image_count: i64,
pub sort_order: i64,
pub created_at: String,
pub updated_at: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -113,6 +126,7 @@ pub struct TaggingJob {
pub image_id: i64, pub image_id: i64,
pub folder_id: i64, pub folder_id: i64,
pub path: String, pub path: String,
pub thumbnail_path: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -283,6 +297,41 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id); CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source); CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cover_image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
-- Forward-compat for smart albums (saved searches); unused in v1.
-- 'manual' = a curated set held in album_images.
kind TEXT NOT NULL DEFAULT 'manual',
query_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS album_images (
album_id INTEGER NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (album_id, image_id)
);
CREATE INDEX IF NOT EXISTS idx_album_images_album ON album_images(album_id);
CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_id);
CREATE TABLE IF NOT EXISTS image_colors (
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
idx INTEGER NOT NULL,
r INTEGER NOT NULL,
g INTEGER NOT NULL,
b INTEGER NOT NULL,
weight REAL NOT NULL,
PRIMARY KEY (image_id, idx)
);
CREATE INDEX IF NOT EXISTS idx_image_colors_image ON image_colors(image_id);
", ",
)?; )?;
@@ -322,6 +371,11 @@ pub fn migrate(conn: &Connection) -> Result<()> {
// on databases that predate Phase 1. // on databases that predate Phase 1.
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?;
ensure_column(conn, "folders", "scan_error", "TEXT")?; ensure_column(conn, "folders", "scan_error", "TEXT")?;
ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?;
conn.execute(
"UPDATE folders SET sort_order = id WHERE sort_order = 0",
[],
)?;
vector::migrate(conn)?; vector::migrate(conn)?;
Ok(()) Ok(())
@@ -329,7 +383,8 @@ pub fn migrate(conn: &Connection) -> Result<()> {
pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> { pub fn insert_folder(conn: &Connection, path: &str, name: &str) -> Result<i64> {
conn.execute( conn.execute(
"INSERT OR IGNORE INTO folders (path, name) VALUES (?1, ?2)", "INSERT OR IGNORE INTO folders (path, name, sort_order)
VALUES (?1, ?2, COALESCE((SELECT MAX(sort_order) + 1 FROM folders), 1))",
params![path, name], params![path, name],
)?; )?;
let id: i64 = conn.query_row( let id: i64 = conn.query_row(
@@ -489,6 +544,41 @@ pub fn repair_embedding_consistency(conn: &Connection) -> Result<(usize, usize)>
Ok((orphaned_vectors, missing_vector_ids.len())) Ok((orphaned_vectors, missing_vector_ids.len()))
} }
/// Full semantic-index rebuild: recreate the vector tables at the current model
/// dimension and re-queue every image for embedding. Used by the maintenance
/// action when the index is stale or its dimension no longer matches the model
/// (which otherwise surfaces as a "dimension mismatch" search error). Returns the
/// number of images re-queued.
pub fn reset_all_embeddings(conn: &Connection) -> Result<usize> {
// Recreate the vector tables at the current model dimension first (DDL, kept
// out of the transaction below). The caller holds the DB write lock, so the
// embedding worker can't interleave a write between this and the queue reset.
vector::rebuild_tables(conn)?;
let tx = conn.unchecked_transaction()?;
tx.execute(
"UPDATE images
SET embedding_status = 'pending',
embedding_model = NULL,
embedding_updated_at = NULL,
embedding_error = NULL",
[],
)?;
tx.execute("DELETE FROM embedding_jobs", [])?;
let queued = tx.execute(
"INSERT INTO embedding_jobs (image_id, status, attempts, last_error, created_at, updated_at)
SELECT id, 'pending', 0, NULL, datetime('now'), datetime('now')
FROM images",
[],
)?;
tx.execute(
"INSERT INTO app_kv (key, value) VALUES ('embedding_revision', 1)
ON CONFLICT(key) DO UPDATE SET value = value + 1",
[],
)?;
tx.commit()?;
Ok(queued)
}
pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> { pub fn retry_failed_embedding_jobs(conn: &Connection, folder_id: i64) -> Result<usize> {
// Only re-queue images that are actually embeddable right now. // Only re-queue images that are actually embeddable right now.
// Videos without a thumbnail would just fail again immediately, so skip them — // Videos without a thumbnail would just fail again immediately, so skip them —
@@ -814,6 +904,7 @@ pub fn get_pending_embedding_jobs(
FROM embedding_jobs j FROM embedding_jobs j
JOIN images i ON i.id = j.image_id JOIN images i ON i.id = j.image_id
WHERE status = 'pending' WHERE status = 'pending'
AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL)
{} {}
ORDER BY j.updated_at, j.image_id ORDER BY j.updated_at, j.image_id
LIMIT ?1", LIMIT ?1",
@@ -1159,14 +1250,14 @@ fn get_pending_thumbnail_jobs_excluding(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
/// Video jobs need FFmpeg; while it isn't provisioned they must be invisible /// Video and AVIF thumbnail jobs need FFmpeg; while it isn't provisioned they
/// to both claiming and the tier-priority checks, or pending video jobs would /// must be invisible to both claiming and tier-priority checks, or pending
/// stall every lower tier indefinitely. /// FFmpeg-backed jobs would stall every lower tier indefinitely.
fn media_kind_clause(include_videos: bool) -> &'static str { fn media_kind_clause(include_videos: bool) -> &'static str {
if include_videos { if include_videos {
"" ""
} else { } else {
"AND i.media_kind = 'image'" "AND i.media_kind = 'image' AND lower(i.path) NOT GLOB '*.avif'"
} }
} }
@@ -1224,7 +1315,12 @@ pub fn has_claimable_embedding_jobs(
conn: &Connection, conn: &Connection,
excluded_folder_ids: &std::collections::HashSet<i64>, excluded_folder_ids: &std::collections::HashSet<i64>,
) -> Result<bool> { ) -> Result<bool> {
has_claimable_jobs(conn, "embedding_jobs", "", excluded_folder_ids) has_claimable_jobs(
conn,
"embedding_jobs",
"AND NOT (i.media_kind = 'video' AND i.thumbnail_path IS NULL)",
excluded_folder_ids,
)
} }
pub fn claim_thumbnail_jobs( pub fn claim_thumbnail_jobs(
@@ -1507,7 +1603,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> { pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name", "SELECT id, path, name, image_count, indexed_at, scan_error, sort_order
FROM folders
ORDER BY sort_order, id",
)?; )?;
let rows = stmt.query_map([], |row| { let rows = stmt.query_map([], |row| {
Ok(Folder { Ok(Folder {
@@ -1517,11 +1615,394 @@ pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
image_count: row.get(3)?, image_count: row.get(3)?,
indexed_at: row.get(4)?, indexed_at: row.get(4)?,
scan_error: row.get(5)?, scan_error: row.get(5)?,
sort_order: row.get(6)?,
}) })
})?; })?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
pub fn reorder_folders(conn: &Connection, folder_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, folder_id) in folder_ids.iter().enumerate() {
tx.execute(
"UPDATE folders SET sort_order = ?2 WHERE id = ?1",
params![folder_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
// ── Albums ────────────────────────────────────────────────────────────────────
fn map_album_row(row: &Row<'_>) -> rusqlite::Result<Album> {
Ok(Album {
id: row.get(0)?,
name: row.get(1)?,
cover_image_id: row.get(2)?,
cover_thumbnail_path: row.get(3)?,
image_count: row.get(4)?,
sort_order: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
}
/// SELECT that resolves each album's cover thumbnail (explicit cover, else the
/// first member by position) and live image count. Shared by list/get-one.
const ALBUM_SELECT: &str = "
SELECT a.id, a.name, a.cover_image_id,
(SELECT ci.thumbnail_path FROM images ci
WHERE ci.id = COALESCE(
a.cover_image_id,
(SELECT ai.image_id FROM album_images ai
WHERE ai.album_id = a.id
ORDER BY ai.position, ai.added_at LIMIT 1)
)) AS cover_thumbnail_path,
(SELECT COUNT(*) FROM album_images ai WHERE ai.album_id = a.id) AS image_count,
a.sort_order, a.created_at, a.updated_at
FROM albums a";
pub fn list_albums(conn: &Connection) -> Result<Vec<Album>> {
let sql = format!("{ALBUM_SELECT} ORDER BY a.sort_order, a.id");
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], map_album_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub fn get_album(conn: &Connection, album_id: i64) -> Result<Album> {
let sql = format!("{ALBUM_SELECT} WHERE a.id = ?1");
conn.query_row(&sql, [album_id], map_album_row)
.map_err(Into::into)
}
pub fn create_album(conn: &Connection, name: &str) -> Result<Album> {
let id: i64 = conn.query_row(
"INSERT INTO albums (name, sort_order)
VALUES (?1, COALESCE((SELECT MAX(sort_order) + 1 FROM albums), 1))
RETURNING id",
params![name],
|row| row.get(0),
)?;
get_album(conn, id)
}
pub fn rename_album(conn: &Connection, album_id: i64, new_name: &str) -> Result<()> {
conn.execute(
"UPDATE albums SET name = ?2, updated_at = datetime('now') WHERE id = ?1",
params![album_id, new_name],
)?;
Ok(())
}
pub fn delete_album(conn: &Connection, album_id: i64) -> Result<()> {
// album_images rows cascade away via the FK.
conn.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
Ok(())
}
/// Delete many albums at once (membership rows cascade away via the FK).
pub fn delete_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for album_id in album_ids {
tx.execute("DELETE FROM albums WHERE id = ?1", [album_id])?;
}
tx.commit()?;
Ok(())
}
pub fn reorder_albums(conn: &Connection, album_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for (index, album_id) in album_ids.iter().enumerate() {
tx.execute(
"UPDATE albums SET sort_order = ?2 WHERE id = ?1",
params![album_id, index as i64 + 1],
)?;
}
tx.commit()?;
Ok(())
}
/// Append images to an album (idempotent). Returns the number newly added.
pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let mut next_position: i64 = tx.query_row(
"SELECT COALESCE(MAX(position) + 1, 0) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
let mut added = 0i64;
for image_id in image_ids {
let changed = tx.execute(
"INSERT INTO album_images (album_id, image_id, position)
VALUES (?1, ?2, ?3)
ON CONFLICT(album_id, image_id) DO NOTHING",
params![album_id, image_id, next_position],
)?;
if changed > 0 {
next_position += 1;
added += 1;
}
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(added)
}
pub fn remove_images_from_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM album_images WHERE album_id = ?1 AND image_id = ?2",
params![album_id, image_id],
)?;
}
tx.execute(
"UPDATE albums SET updated_at = datetime('now') WHERE id = ?1",
[album_id],
)?;
tx.commit()?;
Ok(())
}
pub fn count_album_images(conn: &Connection, album_id: i64) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*) FROM album_images WHERE album_id = ?1",
[album_id],
|row| row.get(0),
)?;
Ok(count)
}
pub fn get_album_images(
conn: &Connection,
album_id: i64,
sort: &str,
offset: i64,
limit: i64,
) -> Result<Vec<ImageRecord>> {
// Default to curated order (album_images.position); otherwise honor the same
// sort vocabulary as get_images. Albums span folders, so no folder filter.
let order = match sort {
"name_asc" => "i.filename ASC",
"name_desc" => "i.filename DESC",
"date_asc" => "i.modified_at ASC NULLS LAST",
"date_desc" => "i.modified_at DESC NULLS LAST",
"size_asc" => "i.file_size ASC",
"size_desc" => "i.file_size DESC",
"rating_asc" => "i.rating ASC, i.modified_at DESC NULLS LAST",
"rating_desc" => "i.rating DESC, i.modified_at DESC NULLS LAST",
"duration_asc" => "i.duration_ms ASC NULLS LAST",
"duration_desc" => "i.duration_ms DESC NULLS LAST",
"taken_asc" => "COALESCE(i.taken_at, i.modified_at) ASC NULLS LAST",
"taken_desc" => "COALESCE(i.taken_at, i.modified_at) DESC NULLS LAST",
_ => "ai.position ASC",
};
let sql = format!(
"SELECT i.id, i.folder_id, i.path, i.filename, i.thumbnail_path, i.width, i.height, i.file_size, i.created_at, i.modified_at, i.taken_at, i.mime_type,
i.media_kind, i.duration_ms, i.video_codec, i.audio_codec, i.metadata_updated_at, i.metadata_error,
i.favorite, i.rating, i.embedding_status, i.embedding_model, i.embedding_updated_at, i.embedding_error,
i.generated_caption, i.caption_model, i.caption_updated_at, i.caption_error,
i.ai_rating, i.ai_tagger_model, i.ai_tagged_at, i.ai_tagger_error
FROM images i
JOIN album_images ai ON ai.image_id = i.id
WHERE ai.album_id = ?1
ORDER BY {order}
LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![album_id, limit, offset], map_image_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
// ── Bulk image operations ──────────────────────────────────────────────────────
/// Apply favorite and/or rating to many images at once; returns the updated rows.
pub fn bulk_update_details(
conn: &Connection,
image_ids: &[i64],
favorite: Option<bool>,
rating: Option<i64>,
) -> Result<Vec<ImageRecord>> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"UPDATE images
SET favorite = COALESCE(?2, favorite),
rating = COALESCE(?3, rating)
WHERE id = ?1",
params![image_id, favorite, rating],
)?;
}
tx.commit()?;
get_images_by_ids(conn, image_ids)
}
/// Add one or more user tags to many images at once.
pub fn bulk_add_tags(conn: &Connection, image_ids: &[i64], tags: &[String]) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
continue;
}
tx.execute(
"INSERT INTO image_tags (image_id, tag, source, ai_model, confidence, created_at)
VALUES (?1, ?2, 'user', NULL, NULL, datetime('now'))
ON CONFLICT(image_id, tag) DO UPDATE SET
source = 'user',
ai_model = NULL,
confidence = NULL
WHERE source = 'ai'",
params![image_id, trimmed],
)?;
}
}
tx.commit()?;
Ok(())
}
/// Remove a tag (by name) from many images at once.
pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
for image_id in image_ids {
tx.execute(
"DELETE FROM image_tags WHERE image_id = ?1 AND tag = ?2",
params![image_id, tag],
)?;
}
tx.commit()?;
Ok(())
}
// ── Color palettes (color search) ──────────────────────────────────────────────
/// Replace an image's stored color palette. `colors` is `(r, g, b, weight)`.
pub fn replace_image_colors(
conn: &Connection,
image_id: i64,
colors: &[(u8, u8, u8, f32)],
) -> Result<()> {
conn.execute("DELETE FROM image_colors WHERE image_id = ?1", [image_id])?;
for (idx, (r, g, b, weight)) in colors.iter().enumerate() {
conn.execute(
"INSERT INTO image_colors (image_id, idx, r, g, b, weight)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
image_id,
idx as i64,
*r as i64,
*g as i64,
*b as i64,
*weight as f64
],
)?;
}
Ok(())
}
/// Images (with thumbnails) that have no stored palette yet — the backfill set.
/// Returns `(image_id, thumbnail_path)`.
pub fn get_images_missing_colors(conn: &Connection, limit: i64) -> Result<Vec<(i64, String)>> {
let mut stmt = conn.prepare(
"SELECT i.id, i.thumbnail_path
FROM images i
WHERE i.media_kind = 'image'
AND i.thumbnail_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)
LIMIT ?1",
)?;
let rows = stmt.query_map([limit], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
/// Count of images still awaiting palette extraction (for backfill progress).
pub fn count_images_missing_colors(conn: &Connection) -> Result<i64> {
let count = conn.query_row(
"SELECT COUNT(*)
FROM images i
WHERE i.media_kind = 'image'
AND i.thumbnail_path IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)",
[],
|row| row.get(0),
)?;
Ok(count)
}
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
let pattern = "No thumbnail available yet for%";
let repaired = conn.execute(
"UPDATE embedding_jobs
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
WHERE status = 'failed'
AND last_error LIKE ?1
AND image_id IN (
SELECT id FROM images WHERE media_kind = 'video' OR lower(path) GLOB '*.avif'
)",
[pattern],
)?;
conn.execute(
"UPDATE images
SET embedding_status = 'pending', embedding_error = NULL
WHERE embedding_status = 'failed'
AND embedding_error LIKE ?1
AND (media_kind = 'video' OR lower(path) GLOB '*.avif')",
[pattern],
)?;
Ok(repaired)
}
pub fn repair_avif_jobs(conn: &Connection) -> Result<usize> {
let unsupported_pattern = "%Avif%not supported%";
let thumbnail_repaired = conn.execute(
"UPDATE thumbnail_jobs
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
WHERE status = 'failed'
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
AND last_error LIKE ?1",
[unsupported_pattern],
)?;
let embedding_repaired = conn.execute(
"UPDATE embedding_jobs
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
WHERE status = 'failed'
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
AND last_error LIKE ?1",
[unsupported_pattern],
)?;
conn.execute(
"UPDATE images
SET embedding_status = 'pending', embedding_error = NULL
WHERE embedding_status = 'failed'
AND lower(path) GLOB '*.avif'
AND embedding_error LIKE ?1",
[unsupported_pattern],
)?;
let tagging_repaired = conn.execute(
"UPDATE tagging_jobs
SET status = 'pending', last_error = NULL, updated_at = datetime('now')
WHERE status = 'failed'
AND image_id IN (SELECT id FROM images WHERE lower(path) GLOB '*.avif')
AND last_error LIKE ?1",
[unsupported_pattern],
)?;
conn.execute(
"UPDATE images
SET ai_tagger_error = NULL
WHERE lower(path) GLOB '*.avif'
AND ai_tagger_error LIKE ?1",
[unsupported_pattern],
)?;
Ok(thumbnail_repaired + embedding_repaired + tagging_repaired)
}
pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> { pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Result<()> {
conn.execute( conn.execute(
"UPDATE folders SET name = ?2 WHERE id = ?1", "UPDATE folders SET name = ?2 WHERE id = ?1",
@@ -1581,6 +2062,8 @@ pub fn get_images(
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
sort: &str, sort: &str,
offset: i64, offset: i64,
limit: i64, limit: i64,
@@ -1604,6 +2087,11 @@ pub fn get_images(
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
let sql = format!( let sql = format!(
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
@@ -1617,8 +2105,15 @@ pub fn get_images(
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed') AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
AND (?10 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = images.id
AND c.weight >= ?15
AND ((c.r - ?11)*(c.r - ?11) + (c.g - ?12)*(c.g - ?12) + (c.b - ?13)*(c.b - ?13)) <= ?14
))
ORDER BY {order} ORDER BY {order}
LIMIT ?7 OFFSET ?8" LIMIT ?8 OFFSET ?9"
); );
let mut stmt = conn.prepare(&sql)?; let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map( let rows = stmt.query_map(
@@ -1629,14 +2124,22 @@ pub fn get_images(
favorites_flag, favorites_flag,
rating_min, rating_min,
embedding_failed_flag, embedding_failed_flag,
tagging_failed_flag,
limit, limit,
offset offset,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
], ],
map_image_row, map_image_row,
)?; )?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
#[allow(clippy::too_many_arguments)]
pub fn count_images( pub fn count_images(
conn: &Connection, conn: &Connection,
folder_id: Option<i64>, folder_id: Option<i64>,
@@ -1645,11 +2148,18 @@ pub fn count_images(
favorites_only: bool, favorites_only: bool,
rating_min: i64, rating_min: i64,
embedding_failed_only: bool, embedding_failed_only: bool,
tagging_failed_only: bool,
color: Option<(u8, u8, u8)>,
) -> Result<i64> { ) -> Result<i64> {
let search_pattern = search.map(|value| format!("%{value}%")); let search_pattern = search.map(|value| format!("%{value}%"));
let favorites_flag = i64::from(favorites_only); let favorites_flag = i64::from(favorites_only);
let embedding_failed_flag = i64::from(embedding_failed_only); let embedding_failed_flag = i64::from(embedding_failed_only);
let tagging_failed_flag = i64::from(tagging_failed_only);
let (color_flag, qr, qg, qb) = match color {
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
None => (0, 0, 0, 0),
};
let count = conn.query_row( let count = conn.query_row(
"SELECT COUNT(*) FROM images "SELECT COUNT(*) FROM images
WHERE (?1 IS NULL OR folder_id = ?1) WHERE (?1 IS NULL OR folder_id = ?1)
@@ -1657,14 +2167,28 @@ pub fn count_images(
AND (?3 IS NULL OR media_kind = ?3) AND (?3 IS NULL OR media_kind = ?3)
AND (?4 = 0 OR favorite = 1) AND (?4 = 0 OR favorite = 1)
AND rating >= ?5 AND rating >= ?5
AND (?6 = 0 OR embedding_status = 'failed')", AND (?6 = 0 OR embedding_status = 'failed')
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
AND (?8 = 0 OR EXISTS (
SELECT 1 FROM image_colors c
WHERE c.image_id = images.id
AND c.weight >= ?13
AND ((c.r - ?9)*(c.r - ?9) + (c.g - ?10)*(c.g - ?10) + (c.b - ?11)*(c.b - ?11)) <= ?12
))",
params![ params![
folder_id, folder_id,
search_pattern, search_pattern,
media_kind, media_kind,
favorites_flag, favorites_flag,
rating_min, rating_min,
embedding_failed_flag embedding_failed_flag,
tagging_failed_flag,
color_flag,
qr,
qg,
qb,
crate::color::MATCH_DISTANCE_SQ,
crate::color::MATCH_MIN_WEIGHT
], ],
|row| row.get(0), |row| row.get(0),
)?; )?;
@@ -1876,12 +2400,14 @@ pub fn get_explore_tags(
Ok(rows) Ok(rows)
} }
type FailedEmbeddingRow = (i64, String, String, Option<String>);
pub fn get_failed_embedding_images( pub fn get_failed_embedding_images(
conn: &Connection, conn: &Connection,
folder_id: i64, folder_id: i64,
) -> Result<Vec<(i64, String, Option<String>)>> { ) -> Result<Vec<FailedEmbeddingRow>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, filename, embedding_error "SELECT id, filename, path, embedding_error
FROM images FROM images
WHERE folder_id = ?1 AND embedding_status = 'failed' WHERE folder_id = ?1 AND embedding_status = 'failed'
ORDER BY filename", ORDER BY filename",
@@ -1890,7 +2416,32 @@ pub fn get_failed_embedding_images(
Ok(( Ok((
row.get::<_, i64>(0)?, row.get::<_, i64>(0)?,
row.get::<_, String>(1)?, row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?, row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
type FailedTaggingRow = (i64, String, String, Option<String>);
pub fn get_failed_tagging_images(
conn: &Connection,
folder_id: i64,
) -> Result<Vec<FailedTaggingRow>> {
let mut stmt = conn.prepare(
"SELECT i.id, i.filename, i.path, COALESCE(j.last_error, i.ai_tagger_error)
FROM images i
LEFT JOIN tagging_jobs j ON j.image_id = i.id AND j.status = 'failed'
WHERE i.folder_id = ?1 AND i.ai_tagger_error IS NOT NULL
ORDER BY i.filename",
)?;
let rows = stmt.query_map([folder_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
)) ))
})?; })?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
@@ -1980,10 +2531,11 @@ fn get_pending_tagging_jobs_excluding(
limit: usize, limit: usize,
) -> Result<Vec<TaggingJob>> { ) -> Result<Vec<TaggingJob>> {
let sql = format!( let sql = format!(
"SELECT j.image_id, i.folder_id, i.path "SELECT j.image_id, i.folder_id, i.path, i.thumbnail_path
FROM tagging_jobs j FROM tagging_jobs j
JOIN images i ON i.id = j.image_id JOIN images i ON i.id = j.image_id
WHERE j.status = 'pending' WHERE j.status = 'pending'
AND NOT (lower(i.path) GLOB '*.avif' AND i.thumbnail_path IS NULL)
{} {}
ORDER BY j.created_at ASC ORDER BY j.created_at ASC
LIMIT ?1", LIMIT ?1",
@@ -1996,6 +2548,7 @@ fn get_pending_tagging_jobs_excluding(
image_id: row.get(0)?, image_id: row.get(0)?,
folder_id: row.get(1)?, folder_id: row.get(1)?,
path: row.get(2)?, path: row.get(2)?,
thumbnail_path: row.get(3)?,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
@@ -2169,6 +2722,35 @@ pub fn remove_tag(conn: &Connection, tag_id: i64) -> Result<()> {
Ok(()) Ok(())
} }
/// Rename a tag across the whole library, or merge it into an existing tag when
/// `to` already exists. Images that already carry `to` keep a single instance
/// (the colliding source row is dropped).
pub fn rename_tag(conn: &Connection, from: &str, to: &str) -> Result<()> {
let tx = conn.unchecked_transaction()?;
// Move rows where the target tag isn't already on that image; UNIQUE
// collisions are skipped (OR IGNORE)…
tx.execute(
"UPDATE OR IGNORE image_tags SET tag = ?2 WHERE tag = ?1",
params![from, to],
)?;
// …then drop the now-duplicate leftovers still under the old name.
tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![from])?;
// The tag-cloud cache keys on image-id hashes, not tag text, so a rename
// wouldn't invalidate it automatically — clear it.
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(())
}
/// Delete a tag from every image in the library. Returns the number of rows removed.
pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
let tx = conn.unchecked_transaction()?;
let removed = tx.execute("DELETE FROM image_tags WHERE tag = ?1", params![name])? as i64;
tx.execute("DELETE FROM tag_cloud_cache", [])?;
tx.commit()?;
Ok(removed)
}
pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> { pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> {
let inserted = conn.execute( let inserted = conn.execute(
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
+12 -5
View File
@@ -220,19 +220,19 @@ fn load_images(paths: &[PathBuf], image_size: usize) -> Result<Tensor> {
/// Returns the path that should be fed to the CLIP image embedder for a given media file. /// Returns the path that should be fed to the CLIP image embedder for a given media file.
/// ///
/// For videos the thumbnail image is used (because CLIP only understands still images). /// For videos and AVIFs the thumbnail image is used because CLIP preprocessing
/// If a video has no thumbnail yet, an error is returned — the caller should mark the /// only uses decoders from the `image` crate, while AVIF is decoded through
/// embedding job as failed rather than trying to decode the raw video file. /// FFmpeg into a JPEG thumbnail.
pub fn embedding_source_path( pub fn embedding_source_path(
path: &str, path: &str,
thumbnail_path: Option<&str>, thumbnail_path: Option<&str>,
media_kind: &str, media_kind: &str,
) -> Result<PathBuf> { ) -> Result<PathBuf> {
if media_kind == "video" { if media_kind == "video" || is_avif_path(path) {
match thumbnail_path { match thumbnail_path {
Some(thumb) => Ok(PathBuf::from(thumb)), Some(thumb) => Ok(PathBuf::from(thumb)),
None => Err(anyhow::anyhow!( None => Err(anyhow::anyhow!(
"No thumbnail available yet for video '{}' — embedding deferred until thumbnail is generated", "No thumbnail available yet for '{}' — embedding deferred until thumbnail is generated",
std::path::Path::new(path) std::path::Path::new(path)
.file_name() .file_name()
.map(|n| n.to_string_lossy()) .map(|n| n.to_string_lossy())
@@ -243,3 +243,10 @@ pub fn embedding_source_path(
Ok(PathBuf::from(path)) Ok(PathBuf::from(path))
} }
} }
fn is_avif_path(path: &str) -> bool {
std::path::Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
+12 -4
View File
@@ -92,6 +92,7 @@ pub fn find_similar_image_matches(
conn: &Connection, conn: &Connection,
image_id: i64, image_id: i64,
folder_id: Option<i64>, folder_id: Option<i64>,
album_id: Option<i64>,
threshold: f32, threshold: f32,
offset: usize, offset: usize,
limit: usize, limit: usize,
@@ -103,10 +104,17 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()), None => return Ok(Vec::new()),
}; };
// Fetch folder image IDs *before* acquiring the read lock so we don't hold // Build the allowed-id set *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any // the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock. // concurrent ensure_index call waiting for a write lock. Album scope takes
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id { // precedence over folder scope; both reuse the HNSW filtered search.
let allowed_image_ids: Option<Vec<i64>> = if let Some(album_id) = album_id {
let mut stmt = conn.prepare("SELECT image_id FROM album_images WHERE album_id = ?1")?;
let ids = stmt
.query_map([album_id], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(ids)
} else if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))? let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter() .into_iter()
.map(|(id, _)| id) .map(|(id, _)| id)
@@ -122,7 +130,7 @@ pub fn find_similar_image_matches(
}; };
let knbn = (offset + limit).max(limit).saturating_add(32); let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids { let neighbours: Vec<Neighbour> = if let Some(image_ids) = allowed_image_ids {
let mut allowed_ids = image_ids let mut allowed_ids = image_ids
.into_iter() .into_iter()
.filter_map(|allowed_image_id| { .filter_map(|allowed_image_id| {
+192 -18
View File
@@ -14,11 +14,11 @@ use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter, Manager};
use walkdir::WalkDir; use walkdir::WalkDir;
const IMAGE_EXTENSIONS: &[&str] = &[ const IMAGE_EXTENSIONS: &[&str] = &[
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", "heic", "heif", "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif",
]; ];
const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"]; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"];
@@ -190,6 +190,13 @@ pub struct MediaUpdateBatch {
pub images: Vec<ImageRecord>, pub images: Vec<ImageRecord>,
} }
#[derive(Clone, Serialize)]
pub struct ColorBackfillProgress {
pub processed: i64,
pub total: i64,
pub done: bool,
}
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
pub struct MediaJobProgressEvent { pub struct MediaJobProgressEvent {
pub progress: Vec<FolderJobProgress>, pub progress: Vec<FolderJobProgress>,
@@ -347,7 +354,51 @@ pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf)
}); });
} }
/// True when `path` lives inside Phokus's own app-data directory (thumbnail
/// cache, database, downloaded models). If a user indexes an ancestor of this
/// directory — e.g. their whole `Users` folder — the app would otherwise index
/// its own thumbnails, generate thumbnails of those thumbnails, and loop
/// forever. The whole subtree is pruned from folder scans and ignored by the
/// watcher to break that cycle.
///
/// Comparison is component-aware (so a sibling like `…/phokus-backup` never
/// matches) and case-insensitive on Windows — the indexed root may report a
/// different casing than `app_data_dir` (e.g. `c:\users\…` vs `C:\Users\…`),
/// and a case-sensitive prefix check there would silently let the loop back in.
fn is_within_app_data(path: &Path, app_data_dir: Option<&Path>) -> bool {
let Some(dir) = app_data_dir else {
return false;
};
let mut base = dir.components();
let mut target = path.components();
loop {
let Some(base_component) = base.next() else {
// Consumed every component of the app-data dir while still matching →
// `path` is the app-data dir itself or a descendant.
return true;
};
let Some(target_component) = target.next() else {
// `path` is shorter than the app-data dir, so it cannot be inside it.
return false;
};
let matches = if cfg!(windows) {
base_component
.as_os_str()
.eq_ignore_ascii_case(target_component.as_os_str())
} else {
base_component == target_component
};
if !matches {
return false;
}
}
}
fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> { fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> {
// Resolve our own app-data directory so the walk can skip it (see
// `is_within_app_data`). Resolution failure is non-fatal — we just lose the
// guard, which only matters when indexing an ancestor of the app data dir.
let app_data_dir = app.path().app_data_dir().ok();
let existing_entries = { let existing_entries = {
let conn = pool.get()?; let conn = pool.get()?;
db::get_folder_media_index(&conn, folder_id)? db::get_folder_media_index(&conn, folder_id)?
@@ -364,6 +415,9 @@ fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf)
let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path) let media_paths: Vec<PathBuf> = WalkDir::new(&folder_path)
.follow_links(true) .follow_links(true)
.into_iter() .into_iter()
// Prune our own app-data subtree before descending into it, so we never
// scan (and re-thumbnail) the thumbnail cache.
.filter_entry(|entry| !is_within_app_data(entry.path(), app_data_dir.as_deref()))
.filter_map(|entry| match entry { .filter_map(|entry| match entry {
Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => { Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => {
Some(e.path().to_path_buf()) Some(e.path().to_path_buf())
@@ -550,6 +604,9 @@ fn build_record(
let filename = path.file_name()?.to_string_lossy().to_string(); let filename = path.file_name()?.to_string_lossy().to_string();
let metadata = std::fs::metadata(path).ok()?; let metadata = std::fs::metadata(path).ok()?;
let file_size = metadata.len() as i64; let file_size = metadata.len() as i64;
if file_size == 0 {
return None;
}
let modified_at = metadata.modified().ok().map(|time| { let modified_at = metadata.modified().ok().map(|time| {
let date_time: chrono::DateTime<chrono::Utc> = time.into(); let date_time: chrono::DateTime<chrono::Utc> = time.into();
date_time.to_rfc3339() date_time.to_rfc3339()
@@ -660,10 +717,13 @@ fn process_thumbnail_batch(
let (image_jobs, video_jobs): (Vec<_>, Vec<_>) = let (image_jobs, video_jobs): (Vec<_>, Vec<_>) =
jobs.into_iter().partition(|job| job.media_kind == "image"); jobs.into_iter().partition(|job| job.media_kind == "image");
let (avif_jobs, raster_jobs): (Vec<_>, Vec<_>) = image_jobs
.into_iter()
.partition(|job| is_avif_path(Path::new(&job.path)));
// Images: parallel decode, committed as one batch. // Images: parallel decode, committed as one batch.
if !image_jobs.is_empty() { if !raster_jobs.is_empty() {
let results = image_jobs let results = raster_jobs
.par_iter() .par_iter()
.map(|job| { .map(|job| {
( (
@@ -675,6 +735,15 @@ fn process_thumbnail_batch(
persist_thumbnail_results(app, pool, results)?; persist_thumbnail_results(app, pool, results)?;
} }
// AVIF: FFmpeg-backed decode, like videos. Keep this off the shared rayon
// pool because each subprocess blocks its worker thread.
for job in &avif_jobs {
let result =
thumbnail::generate_avif_thumbnail(media_tools, Path::new(&job.path), cache_dir)
.map(Some);
persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?;
}
// Videos: sequential, off the rayon pool — each ffmpeg call blocks its // Videos: sequential, off the rayon pool — each ffmpeg call blocks its
// thread, and a video-heavy batch on the shared pool would starve image // thread, and a video-heavy batch on the shared pool would starve image
// decoding across all workers. Committed per item so progress keeps // decoding across all workers. Committed per item so progress keeps
@@ -722,6 +791,13 @@ fn persist_thumbnail_results(
width, width,
height, height,
)?); )?);
// Store the dominant-color palette sampled during resizing.
if let Some(thumb) = &generated {
if !thumb.palette.is_empty() {
db::replace_image_colors(&tx, image_id, &thumb.palette)?;
}
}
} }
tx.commit()?; tx.commit()?;
@@ -746,6 +822,82 @@ fn persist_thumbnail_results(
Ok(()) Ok(())
} }
fn is_avif_path(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
}
/// One-shot background pass that samples a color palette from the (already
/// generated) thumbnails of images indexed before color search existed. New
/// images get their palette during thumbnail generation, so this only fills the
/// historical gap. Emits `color-backfill-progress` so the UI can show a count.
pub fn start_color_backfill(app: AppHandle, pool: DbPool) {
std::thread::spawn(move || {
let total = match pool.get() {
Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0),
Err(_) => return,
};
if total == 0 {
return;
}
log::info!("Color backfill: sampling palettes for {total} images.");
let mut processed: i64 = 0;
loop {
let batch = match pool.get() {
Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(),
Err(_) => break,
};
if batch.is_empty() {
break;
}
for (image_id, thumbnail_path) in batch {
let palette = crate::color::extract_palette_from_file(
Path::new(&thumbnail_path),
crate::color::PALETTE_SIZE,
);
let colors: Vec<(u8, u8, u8, f32)> = match palette {
Some(colors) if !colors.is_empty() => colors
.into_iter()
.map(|c| (c.r, c.g, c.b, c.weight))
.collect(),
// Unreadable thumbnail: store a zero-weight sentinel so the
// image isn't reprocessed forever (it just won't match).
_ => vec![(0, 0, 0, 0.0)],
};
let _ = with_db_write_lock(|| {
let conn = pool.get()?;
db::replace_image_colors(&conn, image_id, &colors)
});
processed += 1;
}
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: false,
},
);
// Yield so the backfill stays in the background under active use.
std::thread::sleep(Duration::from_millis(15));
}
log::info!("Color backfill complete: {processed} images sampled.");
let _ = app.emit(
"color-backfill-progress",
ColorBackfillProgress {
processed,
total,
done: true,
},
);
});
}
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
/// the queue was empty. /// the queue was empty.
fn process_metadata_batch( fn process_metadata_batch(
@@ -869,20 +1021,20 @@ fn process_embedding_batch(
let embedder = embedder.as_ref().expect("embedder should be initialized"); let embedder = embedder.as_ref().expect("embedder should be initialized");
let infer_started_at = Instant::now(); let infer_started_at = Instant::now();
// Resolve the source path for each job. Videos without a thumbnail produce an Err // Resolve each source path. Video jobs without thumbnails are not claimable, so an
// here — those jobs are marked failed immediately without going to the embedder. // error here represents a real race or missing thumbnail rather than normal deferral.
let source_results: Vec<Result<PathBuf>> = jobs let source_results: Vec<Result<PathBuf>> = jobs
.iter() .iter()
.map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind)) .map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind))
.collect(); .collect();
// Separate jobs with a valid source from those that fail early (e.g. video with no thumbnail). // Separate jobs with a valid source from genuine early failures.
let mut embeddable_indices: Vec<usize> = Vec::new(); let mut embeddable_indices: Vec<usize> = Vec::new();
let mut embeddable_paths: Vec<PathBuf> = Vec::new(); let mut embeddable_paths: Vec<PathBuf> = Vec::new();
// image_id -> early error message for jobs that cannot be embedded yet // image_id -> early error message for jobs that cannot be embedded yet
let mut pre_failed: HashMap<i64, String> = HashMap::new(); let mut pre_failed: HashMap<i64, String> = HashMap::new();
for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() { for (i, (job, result)) in jobs.iter().zip(source_results).enumerate() {
match result { match result {
Ok(path) => { Ok(path) => {
embeddable_indices.push(i); embeddable_indices.push(i);
@@ -904,16 +1056,13 @@ fn process_embedding_batch(
match embedder.embed_images(&embeddable_paths) { match embedder.embed_images(&embeddable_paths) {
Ok(embeddings) => { Ok(embeddings) => {
for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) { for (job, embedding) in embeddable_jobs.iter().zip(embeddings) {
embed_results.insert(job.image_id, Ok(embedding)); embed_results.insert(job.image_id, Ok(embedding));
} }
} }
Err(batch_error) => { Err(batch_error) => {
log::error!("Embedding batch fallback to per-image mode: {batch_error}"); log::error!("Embedding batch fallback to per-image mode: {batch_error}");
for (job, source_path) in embeddable_jobs for (job, source_path) in embeddable_jobs.into_iter().zip(embeddable_paths) {
.into_iter()
.zip(embeddable_paths.into_iter())
{
embed_results.insert(job.image_id, embedder.embed_image(&source_path)); embed_results.insert(job.image_id, embedder.embed_image(&source_path));
} }
} }
@@ -1142,9 +1291,14 @@ fn process_tagging_batch(
let tag_results = jobs let tag_results = jobs
.iter() .iter()
.map(|job| { .map(|job| {
let source_path = if is_avif_path(Path::new(&job.path)) {
job.thumbnail_path.as_deref().unwrap_or(&job.path)
} else {
&job.path
};
( (
job.clone(), job.clone(),
tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS), tagger_ref.run(Path::new(source_path), tagger::DEFAULT_MAX_TAGS),
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1285,7 +1439,7 @@ fn max_worker_fetch_size(active_folders: &HashSet<i64>) -> usize {
.unwrap_or(StorageProfile::Balanced.worker_fetch_size()) .unwrap_or(StorageProfile::Balanced.worker_fetch_size())
} }
fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> { pub fn with_db_write_lock<T>(operation: impl FnOnce() -> Result<T>) -> Result<T> {
let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(())); let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(()));
let _guard = lock.lock().unwrap(); let _guard = lock.lock().unwrap();
operation() operation()
@@ -1372,7 +1526,6 @@ fn mime_for_ext(ext: &str) -> &'static str {
"webp" => "image/webp", "webp" => "image/webp",
"tiff" | "tif" => "image/tiff", "tiff" | "tif" => "image/tiff",
"avif" => "image/avif", "avif" => "image/avif",
"heic" | "heif" => "image/heif",
"mp4" | "m4v" => "video/mp4", "mp4" | "m4v" => "video/mp4",
"mov" => "video/quicktime", "mov" => "video/quicktime",
"webm" => "video/webm", "webm" => "video/webm",
@@ -1491,6 +1644,10 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
// Spawn the debounce loop on its own thread. // Spawn the debounce loop on its own thread.
let folder_map_thread = Arc::clone(&folder_map); let folder_map_thread = Arc::clone(&folder_map);
// `thumb_dir` is `<app data>/thumbnails`; its parent is the app-data root.
// Watched roots can be ancestors of it (e.g. a whole user profile), so we
// drop any event inside that subtree to avoid re-indexing our own cache.
let app_data_dir = thumb_dir.parent().map(Path::to_path_buf);
std::thread::spawn(move || { std::thread::spawn(move || {
// path → deadline: the earliest instant at which this path should be processed. // path → deadline: the earliest instant at which this path should be processed.
let mut pending: HashMap<PathBuf, Instant> = HashMap::new(); let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
@@ -1533,7 +1690,22 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
{ {
let old = event.paths[0].clone(); let old = event.paths[0].clone();
let new = event.paths[1].clone(); let new = event.paths[1].clone();
if is_supported_media(&old) || is_supported_media(&new) { let old_in_app = is_within_app_data(&old, app_data_dir.as_deref());
let new_in_app = is_within_app_data(&new, app_data_dir.as_deref());
if old_in_app && new_in_app {
// Internal app-data churn (e.g. thumbnail cache) — ignore.
} else if old_in_app || new_in_app {
// Only one side is app-data, so the rename pairing is
// meaningless (we don't track app-data files). Handle the
// legitimate side as an independent create/delete via the
// normal debounce queue: a file moved out of app-data is
// indexed as a create; one moved in is processed as a
// delete (process_watcher_path sees it no longer exists).
let legit = if old_in_app { new } else { old };
if is_supported_media(&legit) {
pending.insert(legit, now + WATCHER_DEBOUNCE);
}
} else if is_supported_media(&old) || is_supported_media(&new) {
// Remove either side from regular pending so it isn't // Remove either side from regular pending so it isn't
// processed as an independent delete/create. // processed as an independent delete/create.
pending.remove(&old); pending.remove(&old);
@@ -1542,7 +1714,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
} }
} else { } else {
for path in event.paths { for path in event.paths {
if is_supported_media(&path) { if is_supported_media(&path)
&& !is_within_app_data(&path, app_data_dir.as_deref())
{
pending.insert(path, now + WATCHER_DEBOUNCE); pending.insert(path, now + WATCHER_DEBOUNCE);
} }
} }
+65 -1
View File
@@ -1,4 +1,5 @@
mod captioner; mod captioner;
mod color;
mod commands; mod commands;
mod db; mod db;
mod embedder; mod embedder;
@@ -45,6 +46,32 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.setup(|app| { .setup(|app| {
// Fresh installs open at the fixed config size (1280×800) because the
// window-state plugin has nothing saved yet — too tall for laptops
// like 1366×768. Clamp the window to the monitor's work area (which
// already excludes the taskbar) and re-center it there, but only when
// it actually overflows, so a restored size/position on a roomier
// display is left untouched. Sizes are physical pixels on both sides,
// so this stays correct across display scaling.
if let Some(window) = app.get_webview_window("main") {
if let Ok(Some(monitor)) = window.current_monitor() {
let area = monitor.work_area();
let max_w = area.size.width.saturating_sub(32);
let max_h = area.size.height.saturating_sub(32);
if let Ok(size) = window.outer_size() {
if size.width > max_w || size.height > max_h {
let new_w = size.width.min(max_w);
let new_h = size.height.min(max_h);
let _ = window.set_size(tauri::PhysicalSize::new(new_w, new_h));
let _ = window.set_position(tauri::PhysicalPosition::new(
area.position.x + (area.size.width as i32 - new_w as i32) / 2,
area.position.y + (area.size.height as i32 - new_h as i32) / 2,
));
}
}
}
}
let app_dir = app let app_dir = app
.path() .path()
.app_data_dir() .app_data_dir()
@@ -53,7 +80,7 @@ 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");
// FFmpeg provisioning happens in the background so the window // FFmpeg provisioning happens in the background so the window
// appears immediately; workers gate video jobs on readiness and // appears immediately; workers gate video/AVIF jobs on readiness and
// the onboarding/Settings UI shows progress and retry. // the onboarding/Settings UI shows progress and retry.
media::spawn_ffmpeg_provision(app.handle().clone()); media::spawn_ffmpeg_provision(app.handle().clone());
@@ -65,6 +92,16 @@ 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 repaired_deferred = db::repair_deferred_embedding_jobs(&conn)
.expect("Failed to repair deferred embedding jobs");
if repaired_deferred > 0 {
log::info!("Requeued {repaired_deferred} deferred embedding jobs.");
}
let repaired_avif =
db::repair_avif_jobs(&conn).expect("Failed to repair AVIF jobs");
if repaired_avif > 0 {
log::info!("Requeued {repaired_avif} AVIF jobs.");
}
let backfilled = let backfilled =
db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs"); db::backfill_embedding_jobs(&conn).expect("Failed to backfill embedding jobs");
if backfilled > 0 { if backfilled > 0 {
@@ -112,6 +149,8 @@ pub fn run() {
// Caption worker disabled — UI removed; keeping backend code intact for future use. // 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_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone()); indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
// Backfill color palettes for images indexed before color search existed.
indexer::start_color_backfill(app.handle().clone(), pool.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone()); let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
@@ -123,7 +162,10 @@ pub fn run() {
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::add_folder, commands::add_folder,
commands::add_folders,
commands::list_directories,
commands::get_folders, commands::get_folders,
commands::reorder_folders,
commands::get_background_job_progress, commands::get_background_job_progress,
commands::remove_folder, commands::remove_folder,
commands::get_images, commands::get_images,
@@ -156,6 +198,7 @@ pub fn run() {
commands::get_explore_tags, commands::get_explore_tags,
commands::get_images_by_ids, commands::get_images_by_ids,
commands::get_failed_embedding_images, commands::get_failed_embedding_images,
commands::get_failed_tagging_images,
commands::get_tagger_model_status, commands::get_tagger_model_status,
commands::get_tagger_acceleration, commands::get_tagger_acceleration,
commands::set_tagger_acceleration, commands::set_tagger_acceleration,
@@ -171,6 +214,22 @@ pub fn run() {
commands::get_image_tags, commands::get_image_tags,
commands::add_user_tag, commands::add_user_tag,
commands::remove_tag, commands::remove_tag,
commands::rename_tag,
commands::delete_tag,
commands::get_image_exif,
commands::list_albums,
commands::create_album,
commands::rename_album,
commands::delete_album,
commands::delete_albums,
commands::reorder_albums,
commands::add_images_to_album,
commands::remove_images_from_album,
commands::get_album_images,
commands::bulk_update_details,
commands::bulk_add_tags,
commands::bulk_remove_tag,
commands::get_build_variant,
commands::search_tags_autocomplete, commands::search_tags_autocomplete,
commands::find_duplicates, commands::find_duplicates,
commands::load_duplicate_scan_cache, commands::load_duplicate_scan_cache,
@@ -183,8 +242,11 @@ pub fn run() {
commands::get_tagging_queue_folder_ids, commands::get_tagging_queue_folder_ids,
commands::set_tagging_queue_folder_ids, commands::set_tagging_queue_folder_ids,
commands::open_app_data_folder, commands::open_app_data_folder,
commands::open_map_location,
commands::open_changelog_url,
commands::get_database_info, commands::get_database_info,
commands::vacuum_database, commands::vacuum_database,
commands::rebuild_semantic_index,
commands::get_orphaned_thumbnails_info, commands::get_orphaned_thumbnails_info,
commands::cleanup_orphaned_thumbnails, commands::cleanup_orphaned_thumbnails,
commands::get_muted_folder_ids, commands::get_muted_folder_ids,
@@ -193,6 +255,8 @@ pub fn run() {
commands::retry_ffmpeg_download, commands::retry_ffmpeg_download,
commands::get_onboarding_completed, commands::get_onboarding_completed,
commands::set_onboarding_completed, commands::set_onboarding_completed,
commands::get_last_seen_version,
commands::set_last_seen_version,
commands::get_notifications_paused, commands::get_notifications_paused,
commands::set_notifications_paused, commands::set_notifications_paused,
]) ])
+2 -7
View File
@@ -45,9 +45,10 @@ pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
// Settings types // Settings types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum TaggerAcceleration { pub enum TaggerAcceleration {
#[default]
Auto, Auto,
Cpu, Cpu,
Directml, Directml,
@@ -63,12 +64,6 @@ impl TaggerAcceleration {
} }
} }
impl Default for TaggerAcceleration {
fn default() -> Self {
Self::Auto
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Status / probe types exposed to the frontend // Status / probe types exposed to the frontend
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+107
View File
@@ -12,6 +12,9 @@ pub struct GeneratedThumbnail {
pub path: PathBuf, pub path: PathBuf,
pub width: Option<i64>, pub width: Option<i64>,
pub height: Option<i64>, pub height: Option<i64>,
/// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty
/// when the thumbnail already existed (the color backfill handles those).
pub palette: Vec<(u8, u8, u8, f32)>,
} }
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> { pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
@@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette: Vec::new(),
}); });
} }
@@ -47,6 +51,12 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec()) 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"))?;
// Sample the dominant-color palette from the resized buffer before encoding.
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
if let Some(parent) = out_path.parent() { if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -58,6 +68,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
path: out_path, path: out_path,
width: original_dimensions.0, width: original_dimensions.0,
height: original_dimensions.1, height: original_dimensions.1,
palette,
}) })
} }
@@ -166,6 +177,7 @@ pub fn generate_video_thumbnail(
path: out_path, path: out_path,
width: None, width: None,
height: None, height: None,
palette: Vec::new(),
}); });
} }
@@ -231,6 +243,7 @@ pub fn generate_video_thumbnail(
path: out_path, path: out_path,
width: None, width: None,
height: None, height: None,
palette: Vec::new(),
}); });
} }
last_error = String::from_utf8_lossy(&output.stderr).to_string(); last_error = String::from_utf8_lossy(&output.stderr).to_string();
@@ -241,6 +254,100 @@ pub fn generate_video_thumbnail(
)) ))
} }
pub fn generate_avif_thumbnail(
tools: &MediaTools,
image_path: &Path,
cache_dir: &Path,
) -> Result<GeneratedThumbnail> {
let path_str = image_path.to_string_lossy();
let out_path = thumb_path(cache_dir, &path_str);
let original_dimensions = ffprobe_dimensions(tools, image_path);
if out_path.exists() {
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette: Vec::new(),
});
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let output_path = out_path.to_string_lossy().into_owned();
let output = tools
.ffmpeg_command()
.args([
"-y",
"-threads",
"2",
"-i",
path_str.as_ref(),
"-frames:v",
"1",
"-vf",
"scale=320:-1:force_original_aspect_ratio=decrease",
"-q:v",
"4",
&output_path,
])
.output()?;
if output.status.success() && out_path.exists() {
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
// sample the palette by reading the JPEG thumbnail we just wrote.
let palette =
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
.unwrap_or_default()
.into_iter()
.map(|color| (color.r, color.g, color.b, color.weight))
.collect();
return Ok(GeneratedThumbnail {
path: out_path,
width: original_dimensions.0,
height: original_dimensions.1,
palette,
});
}
Err(anyhow!(
"ffmpeg failed generating AVIF thumbnail for {path_str}: {}",
String::from_utf8_lossy(&output.stderr)
))
}
fn ffprobe_dimensions(tools: &MediaTools, image_path: &Path) -> (Option<i64>, Option<i64>) {
let output = tools
.ffprobe_command()
.args([
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"csv=p=0:s=x",
&image_path.to_string_lossy(),
])
.output();
let Ok(output) = output else {
return (None, None);
};
if !output.status.success() {
return (None, None);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut parts = stdout.trim().split('x');
let width = parts.next().and_then(|part| part.parse::<i64>().ok());
let height = parts.next().and_then(|part| part.parse::<i64>().ok());
(width, height)
}
pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf { pub fn thumb_path(cache_dir: &Path, image_path: &str) -> PathBuf {
thumb_path_with_ext(cache_dir, image_path, "jpg") thumb_path_with_ext(cache_dir, image_path, "jpg")
} }
+47
View File
@@ -36,6 +36,18 @@ pub fn migrate(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// Drop and recreate the vector tables at the current `CLIP_VECTOR_DIM`. Used by
/// the "Rebuild semantic index" maintenance action when stored vectors no longer
/// match the active model's dimension (e.g. after switching embedding models),
/// so the columns are rebuilt to the right size before embeddings regenerate.
pub fn rebuild_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
"DROP TABLE IF EXISTS image_vec;
DROP TABLE IF EXISTS caption_vec;",
)?;
migrate(conn)
}
#[allow(dead_code)] #[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])?;
@@ -360,6 +372,41 @@ pub fn search_image_ids_by_embedding_in_folder(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
/// Brute-force cosine search scoped to a single album (membership via
/// `album_images`), ordered by ascending distance. Mirrors the folder-scoped
/// variant for region-based similarity search.
pub fn search_image_ids_by_embedding_in_album(
conn: &Connection,
embedding: &[f32],
album_id: i64,
exclude_image_id: Option<i64>,
limit: usize,
) -> Result<Vec<i64>> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
let exclude_id = exclude_image_id.unwrap_or(-1);
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN album_images ai ON ai.image_id = v.image_id
WHERE ai.album_id = ?2
AND v.image_id != ?3
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
LIMIT ?4",
)?;
let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn search_caption_ids_by_embedding( pub fn search_caption_ids_by_embedding(
conn: &Connection, conn: &Connection,
+12 -1
View File
@@ -10,7 +10,10 @@ import { DuplicateFinder } from "./components/DuplicateFinder";
import { Timeline } from "./components/Timeline"; import { Timeline } from "./components/Timeline";
import { TitleBar } from "./components/TitleBar"; import { TitleBar } from "./components/TitleBar";
import { SettingsModal } from "./components/SettingsModal"; import { SettingsModal } from "./components/SettingsModal";
import { FolderPickerModal } from "./components/FolderPickerModal";
import { UpdateToast } from "./components/UpdateToast"; import { UpdateToast } from "./components/UpdateToast";
import { WhatsNewToast } from "./components/WhatsNewToast";
import { WhatsNewModal } from "./components/WhatsNewModal";
import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay"; import { OnboardingOverlay } from "./components/onboarding/OnboardingOverlay";
import { DemoPanel } from "./components/DemoPanel"; import { DemoPanel } from "./components/DemoPanel";
import { initializeNotifications } from "./notifications"; import { initializeNotifications } from "./notifications";
@@ -21,6 +24,7 @@ export default function App() {
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused);
const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress);
@@ -28,15 +32,18 @@ export default function App() {
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus); const loadFfmpegStatus = useGalleryStore((state) => state.loadFfmpegStatus);
const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted); const loadOnboardingCompleted = useGalleryStore((state) => state.loadOnboardingCompleted);
const initWhatsNew = useGalleryStore((state) => state.initWhatsNew);
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
useEffect(() => { useEffect(() => {
void initializeNotifications(); void initializeNotifications();
void loadMutedFolderIds(); void loadMutedFolderIds();
void loadNotificationsPaused(); void loadNotificationsPaused();
void loadAppVersion();
void loadFfmpegStatus(); void loadFfmpegStatus();
void loadOnboardingCompleted(); void loadOnboardingCompleted();
// Load the app version first so the What's New toast/modal (which read
// appVersion from the store) have it before the greeting can appear.
void loadAppVersion().then(() => initWhatsNew());
// Quiet launch check — dev builds have no signed artifacts to update to. // Quiet launch check — dev builds have no signed artifacts to update to.
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
void checkForUpdates({ quiet: true }); void checkForUpdates({ quiet: true });
@@ -45,6 +52,7 @@ export default function App() {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
void loadDuplicateScanCache(); void loadDuplicateScanCache();
void loadAlbums();
return loadImages(true); return loadImages(true);
}); });
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
@@ -93,7 +101,10 @@ export default function App() {
<Lightbox /> <Lightbox />
<SettingsModal /> <SettingsModal />
<FolderPickerModal />
<UpdateToast /> <UpdateToast />
<WhatsNewToast />
<WhatsNewModal />
<OnboardingOverlay /> <OnboardingOverlay />
{import.meta.env.DEV && <DemoPanel />} {import.meta.env.DEV && <DemoPanel />}
</div> </div>
+112
View File
@@ -0,0 +1,112 @@
// Parses the project CHANGELOG.md (imported raw at build time) into structured
// data so the "What's New" UI can render it nicely instead of dumping markdown.
// Keeping the changelog as the single source of truth means there's no separate
// per-release copy to maintain — whatever ships in CHANGELOG.md is what users see.
import changelogRaw from "../CHANGELOG.md?raw";
export interface ChangelogItem {
/** The bold lead-in at the start of a bullet (e.g. "Custom multi-folder picker"), if any. */
lead: string | null;
/** The remaining descriptive text. May still contain inline `code` / **bold** markers. */
body: string;
}
export interface ChangelogSection {
/** "Added" | "Changed" | "Fixed" | "Removed" | "Deprecated" | "Security" */
title: string;
items: ChangelogItem[];
}
export interface ChangelogEntry {
version: string;
date: string | null;
sections: ChangelogSection[];
}
// "## [0.1.1] — 2026-06-23" / "## [Unreleased]"
const VERSION_HEADING = /^##\s+\[([^\]]+)\]\s*(?:[—–-]\s*(.+?)\s*)?$/;
// "### Added"
const SECTION_HEADING = /^###\s+(.+?)\s*$/;
// "- bullet text"
const BULLET = /^[-*]\s+(.*)$/;
// Leading "**Title**" optionally followed by an em dash, used as the item's lead-in.
// (Item text is whitespace-collapsed before matching, so no dotAll flag needed.)
const LEAD = /^\*\*(.+?)\*\*\s*(?:[—–-]\s*)?(.*)$/;
function toItem(text: string): ChangelogItem {
const collapsed = text.replace(/\s+/g, " ").trim();
const match = collapsed.match(LEAD);
if (match) {
return { lead: match[1].trim(), body: match[2].trim() };
}
return { lead: null, body: collapsed };
}
function parseChangelog(raw: string): ChangelogEntry[] {
const lines = raw.split(/\r?\n/);
const entries: ChangelogEntry[] = [];
let entry: ChangelogEntry | null = null;
let section: ChangelogSection | null = null;
let buffer: string[] = [];
const flushItem = () => {
if (section && buffer.length > 0) {
section.items.push(toItem(buffer.join(" ")));
}
buffer = [];
};
for (const line of lines) {
const versionMatch = line.match(VERSION_HEADING);
if (versionMatch) {
flushItem();
section = null;
entry = { version: versionMatch[1].trim(), date: versionMatch[2]?.trim() ?? null, sections: [] };
entries.push(entry);
continue;
}
// Stop collecting once we leave the changelog body (e.g. link-reference defs at EOF).
if (!entry) continue;
const sectionMatch = line.match(SECTION_HEADING);
if (sectionMatch) {
flushItem();
section = { title: sectionMatch[1].trim(), items: [] };
entry.sections.push(section);
continue;
}
const bulletMatch = line.match(BULLET);
if (bulletMatch) {
flushItem();
buffer.push(bulletMatch[1]);
continue;
}
if (line.trim() === "") {
// A blank line ends a (possibly wrapped) multi-line bullet.
flushItem();
continue;
}
// Indented continuation of the current wrapped bullet.
if (buffer.length > 0) {
buffer.push(line.trim());
}
}
flushItem();
return entries.map((e) => ({ ...e, sections: e.sections.filter((s) => s.items.length > 0) }));
}
const ENTRIES = parseChangelog(changelogRaw);
export function getChangelogForVersion(version: string | null | undefined): ChangelogEntry | null {
if (!version) return null;
const normalized = version.replace(/^v/, "");
// Never surface the in-progress [Unreleased] section to users.
if (normalized.toLowerCase() === "unreleased") return null;
return ENTRIES.find((e) => e.version.replace(/^v/, "") === normalized) ?? null;
}
+130 -46
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, WorkerKey } from "../store"; import { useGalleryStore, WorkerKey } from "../store";
const WORKER_FOR_STAGE: Record<string, WorkerKey> = { const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
@@ -20,6 +21,7 @@ interface Task {
id: number; id: number;
name: string; name: string;
stages: TaskStage[]; stages: TaskStage[];
isActive: boolean;
hasFailedEmbeddings: boolean; hasFailedEmbeddings: boolean;
hasFailedTagging: boolean; hasFailedTagging: boolean;
hasFailedCaptions: boolean; hasFailedCaptions: boolean;
@@ -30,23 +32,52 @@ interface Task {
snapshot: string; snapshot: string;
} }
interface FailedEmbeddingItem { interface FailedWorkerItem {
image_id: number; image_id: number;
filename: string; filename: string;
path: string;
error: string | null; error: string | null;
} }
function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) {
return (
<div className="flex min-w-0 items-start gap-1.5">
<svg className="mt-px h-2.5 w-2.5 shrink-0 text-amber-500 light-theme:text-amber-700" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-[10px] font-medium text-amber-400/80 light-theme:text-amber-700">{item.filename}</p>
{item.error && (
<p className="truncate text-[9px] text-gray-600">{item.error}</p>
)}
</div>
<button
className="shrink-0 text-gray-700 transition-colors hover:text-gray-300 light-theme:text-gray-600 light-theme:hover:text-gray-100"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(item.path)}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
);
}
export function BackgroundTasks() { export function BackgroundTasks() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
const showFailedTagging = useGalleryStore((state) => state.showFailedTagging);
const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning);
const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [dismissed, setDismissed] = useState<Record<number, string>>({}); const [dismissed, setDismissed] = useState<Record<number, string>>({});
const [failedItems, setFailedItems] = useState<Record<number, FailedEmbeddingItem[]>>({}); const [failedEmbeddingItems, setFailedEmbeddingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const [failedTaggingItems, setFailedTaggingItems] = useState<Record<number, FailedWorkerItem[]>>({});
const workerPaused = useGalleryStore((state) => state.workerPaused); const workerPaused = useGalleryStore((state) => state.workerPaused);
const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates);
@@ -56,27 +87,43 @@ export function BackgroundTasks() {
void loadWorkerStates(); void loadWorkerStates();
}, [folders, loadWorkerStates]); }, [folders, loadWorkerStates]);
// Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. // Fetch failed filenames whenever the expanded panel opens or failure counts change.
const failedCounts = useMemo( const failedEmbeddingCounts = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]),
), ),
[mediaJobProgress], [mediaJobProgress],
); );
const failedTaggingCounts = useMemo(
() =>
Object.fromEntries(
Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]),
),
[mediaJobProgress],
);
useEffect(() => { useEffect(() => {
if (!expanded) return; if (!expanded) return;
for (const [folderId, count] of Object.entries(failedCounts)) { for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) {
if (count > 0) { if (count > 0) {
invoke<FailedEmbeddingItem[]>("get_failed_embedding_images", { invoke<FailedWorkerItem[]>("get_failed_embedding_images", {
folderId: Number(folderId), folderId: Number(folderId),
}) })
.then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items }))) .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined); .catch(() => undefined);
} }
} }
}, [expanded, failedCounts]); for (const [folderId, count] of Object.entries(failedTaggingCounts)) {
if (count > 0) {
invoke<FailedWorkerItem[]>("get_failed_tagging_images", {
folderId: Number(folderId),
})
.then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items })))
.catch(() => undefined);
}
}
}, [expanded, failedEmbeddingCounts, failedTaggingCounts]);
const isWorkerPaused = (folderId: number, worker: WorkerKey) => { const isWorkerPaused = (folderId: number, worker: WorkerKey) => {
return workerPaused[folderId]?.[worker] ?? false; return workerPaused[folderId]?.[worker] ?? false;
@@ -110,6 +157,18 @@ export function BackgroundTasks() {
const captionReady = jobs?.caption_ready ?? 0; const captionReady = jobs?.caption_ready ?? 0;
const captionFailed = jobs?.caption_failed ?? 0; const captionFailed = jobs?.caption_failed ?? 0;
// A folder is "actively processing" when something is genuinely moving:
// an in-progress scan, or pending work on a worker that isn't paused.
// Captions have no per-folder pause toggle, so any caption work counts.
const paused = workerPaused[folder.id];
const isActive =
(!!index && !index.done) ||
(thumbnailPending > 0 && !(paused?.thumbnail ?? false)) ||
(metadataPending > 0 && !(paused?.metadata ?? false)) ||
(embeddingPending > 0 && !(paused?.embedding ?? false)) ||
(taggingPending > 0 && !(paused?.tagging ?? false)) ||
captionPending > 0;
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending; const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending + captionPending;
const embeddingProcessed = embeddingReady + embeddingFailed; const embeddingProcessed = embeddingReady + embeddingFailed;
const embeddingTotal = embeddingProcessed + embeddingPending; const embeddingTotal = embeddingProcessed + embeddingPending;
@@ -216,6 +275,7 @@ export function BackgroundTasks() {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
stages, stages,
isActive,
hasFailedEmbeddings, hasFailedEmbeddings,
hasFailedTagging, hasFailedTagging,
hasFailedCaptions, hasFailedCaptions,
@@ -227,8 +287,12 @@ export function BackgroundTasks() {
}; };
}) })
.filter((t): t is Task => t !== null) .filter((t): t is Task => t !== null)
.filter((t) => dismissed[t.id] !== t.snapshot); .filter((t) => dismissed[t.id] !== t.snapshot)
}, [folders, indexingProgress, mediaJobProgress, dismissed]); // Surface actively-processing folders first so a paused folder never holds
// the primary (collapsed) slot while another is genuinely working. Array
// sort is stable, so folders within each group keep their existing order.
.sort((a, b) => Number(b.isActive) - Number(a.isActive));
}, [folders, indexingProgress, mediaJobProgress, workerPaused, dismissed]);
// Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed // Synthetic task for duplicate scanning — negative id so dismiss/retry are suppressed
const duplicateScanTask: Task | null = duplicateScanning ? { const duplicateScanTask: Task | null = duplicateScanning ? {
@@ -248,6 +312,7 @@ export function BackgroundTasks() {
: null, : null,
failed: false, failed: false,
}], }],
isActive: true,
hasFailedEmbeddings: false, hasFailedEmbeddings: false,
hasFailedTagging: false, hasFailedTagging: false,
hasFailedCaptions: false, hasFailedCaptions: false,
@@ -302,14 +367,14 @@ export function BackgroundTasks() {
key={stage.label} key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed stage.failed
? "bg-amber-500/10 text-amber-400" ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused : isPaused
? "bg-white/4 text-gray-600" ? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-400" : "bg-white/5 text-gray-400"
}`} }`}
> >
<span>{stage.label}</span> <span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : isPaused ? "text-gray-700" : "text-gray-600"}`}> <span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : isPaused ? "text-gray-700" : "text-gray-600"}`}>
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
@@ -355,14 +420,27 @@ export function BackgroundTasks() {
</span> </span>
)} )}
{/* Retry (failed embeddings only) */} {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && (
{primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( <div className="flex shrink-0 items-center gap-1.5">
<button {primary.hasFailedTagging ? (
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 hover:bg-amber-500/20 transition-colors shrink-0" <button
onClick={(e) => { e.stopPropagation(); void retryFailedEmbeddings(primary.id); }} className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
> onClick={(e) => { e.stopPropagation(); showFailedTagging(primary.id); }}
Retry >
</button> Locate
</button>
) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2.5 py-1 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={(e) => {
e.stopPropagation();
if (primary.hasFailedEmbeddings) void retryFailedEmbeddings(primary.id);
if (primary.hasFailedTagging) void queueTaggingJobs(primary.id);
}}
>
Retry
</button>
</div>
)} )}
{/* Expand chevron (only when multiple tasks) */} {/* Expand chevron (only when multiple tasks) */}
@@ -414,7 +492,7 @@ export function BackgroundTasks() {
key={stage.label} key={stage.label}
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${
stage.failed stage.failed
? "bg-amber-500/10 text-amber-400" ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700"
: isPaused : isPaused
? "bg-white/4 text-gray-600" ? "bg-white/4 text-gray-600"
: "bg-white/5 text-gray-500" : "bg-white/5 text-gray-500"
@@ -426,7 +504,7 @@ export function BackgroundTasks() {
</svg> </svg>
)} )}
<span>{stage.label}</span> <span>{stage.label}</span>
<span className={`tabular-nums ${stage.failed ? "text-amber-500" : "text-gray-600"}`}> <span className={`tabular-nums ${stage.failed ? "text-amber-500 light-theme:text-amber-700" : "text-gray-600"}`}>
{stage.detail} {stage.detail}
</span> </span>
{workerKey && ( {workerKey && (
@@ -465,15 +543,25 @@ export function BackgroundTasks() {
</div> </div>
{taskHasFailed && ( {taskHasFailed && (
<button <div className="flex shrink-0 items-center gap-1.5">
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" {task.hasFailedTagging ? (
onClick={() => { <button
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id); className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
if (task.hasFailedTagging) void queueTaggingJobs(task.id); onClick={() => showFailedTagging(task.id)}
}} >
> Locate
Retry </button>
</button> ) : null}
<button
className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-2 py-0.5 text-[11px] text-amber-300 transition-colors hover:bg-amber-500/20 light-theme:border-amber-500/50 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:hover:bg-amber-200"
onClick={() => {
if (task.hasFailedEmbeddings) void retryFailedEmbeddings(task.id);
if (task.hasFailedTagging) void queueTaggingJobs(task.id);
}}
>
Retry
</button>
</div>
)} )}
{task.id >= 0 && ( {task.id >= 0 && (
@@ -495,22 +583,18 @@ export function BackgroundTasks() {
</p> </p>
)} )}
{/* Failed embedding file list */} {/* Failed worker file lists */}
{taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && ( {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
<div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5"> <div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
{failedItems[task.id].map((item) => ( {failedEmbeddingItems[task.id].map((item) => (
<div key={item.image_id} className="flex items-start gap-1.5 min-w-0"> <FailedWorkerItemRow key={item.image_id} item={item} />
<svg className="h-2.5 w-2.5 text-amber-500 shrink-0 mt-px" fill="none" viewBox="0 0 24 24" stroke="currentColor"> ))}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} </div>
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /> )}
</svg> {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && (
<div className="min-w-0"> <div className="mt-2 pl-[calc(7rem+0.75rem)] space-y-0.5">
<p className="text-[10px] text-amber-400/80 truncate font-medium">{item.filename}</p> {failedTaggingItems[task.id].map((item) => (
{item.error && ( <FailedWorkerItemRow key={item.image_id} item={item} />
<p className="text-[9px] text-gray-600 truncate">{item.error}</p>
)}
</div>
</div>
))} ))}
</div> </div>
)} )}
+276
View File
@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from "react";
import { useGalleryStore } from "../store";
import { BulkTagPopover } from "./bulk/BulkTagPopover";
type Panel = "tag" | "rating" | "album" | "delete" | null;
export function BulkActionBar() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedIds = useGalleryStore((state) => state.gallerySelectedIds);
const clearGallerySelection = useGalleryStore((state) => state.clearGallerySelection);
const selectAllGallery = useGalleryStore((state) => state.selectAllGallery);
const loadedCount = useGalleryStore((state) => state.loadedCount);
const totalImages = useGalleryStore((state) => state.totalImages);
const bulkSetFavorite = useGalleryStore((state) => state.bulkSetFavorite);
const bulkSetRating = useGalleryStore((state) => state.bulkSetRating);
const bulkDeleteSelected = useGalleryStore((state) => state.bulkDeleteSelected);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const removeFromAlbum = useGalleryStore((state) => state.removeFromAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const [panel, setPanel] = useState<Panel>(null);
const [deleting, setDeleting] = useState(false);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const barRef = useRef<HTMLDivElement>(null);
// Close any open popover when clicking outside the bar.
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
setPanel(null);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, []);
// Reset transient UI whenever the selection empties.
useEffect(() => {
if (selectedCount === 0) {
setPanel(null);
setNewAlbumName("");
}
}, [selectedCount]);
if (selectedCount === 0) return null;
const ids = Array.from(selectedIds);
const inAlbumView = activeView === "album" && selectedAlbumId !== null;
const togglePanel = (next: Exclude<Panel, null>) => setPanel((current) => (current === next ? null : next));
const handleDelete = async () => {
setDeleting(true);
try {
await bulkDeleteSelected();
} finally {
setDeleting(false);
setPanel(null);
}
};
const handlePickAlbum = async (albumId: number) => {
await addToAlbum(albumId, ids);
setPanel(null);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name || creatingAlbum) return;
setCreatingAlbum(true);
try {
const album = await createAlbum(name);
await addToAlbum(album.id, ids);
setNewAlbumName("");
setPanel(null);
} finally {
setCreatingAlbum(false);
}
};
const btn = "rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors";
const btnIdle = `${btn} text-gray-300 hover:bg-white/10 hover:text-white`;
const btnActive = `${btn} bg-white/10 text-white`;
return (
<div
ref={barRef}
className="pointer-events-auto absolute bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/95 px-2 py-1.5 shadow-2xl shadow-black/50 backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center gap-2 px-1.5">
<span className="text-xs font-medium text-white">{selectedCount} selected</span>
{loadedCount < totalImages || loadedCount > selectedCount ? (
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={selectAllGallery}
title={loadedCount < totalImages ? "Selects loaded items only" : "Select all loaded"}
>
Select all{loadedCount < totalImages ? " loaded" : ""}
</button>
) : null}
</div>
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button className={panel === "tag" ? btnActive : btnIdle} onClick={() => togglePanel("tag")}>
Tag
</button>
{panel === "tag" ? <BulkTagPopover onClose={() => setPanel(null)} /> : null}
</div>
<div className="relative">
<button className={panel === "rating" ? btnActive : btnIdle} onClick={() => togglePanel("rating")}>
Rating
</button>
{panel === "rating" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 flex -translate-x-1/2 items-center gap-1 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
{Array.from({ length: 5 }, (_, index) => {
const rating = index + 1;
return (
<button
key={rating}
className="rounded-md p-1 text-white/25 transition-colors hover:bg-white/5 hover:text-amber-300"
onClick={async () => {
await bulkSetRating(rating);
setPanel(null);
}}
title={`Set ${rating} star${rating === 1 ? "" : "s"}`}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</button>
);
})}
<button
className="ml-1 rounded-md border border-white/10 px-2 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
onClick={async () => {
await bulkSetRating(0);
setPanel(null);
}}
>
Clear
</button>
</div>
) : null}
</div>
<button className={btnIdle} onClick={() => void bulkSetFavorite(true)} title="Mark as favorite">
Favorite
</button>
<div className="relative">
<button className={panel === "album" ? btnActive : btnIdle} onClick={() => togglePanel("album")}>
Add to album
</button>
{panel === "album" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-60 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-2 shadow-2xl backdrop-blur"
>
<div className="max-h-48 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-2 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => void handlePickAlbum(album.id)}
>
<span className="truncate">{album.name}</span>
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-2"
onSubmit={(event) => {
event.preventDefault();
void handleCreateAlbum();
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(event) => setNewAlbumName(event.target.value)}
disabled={creatingAlbum}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={creatingAlbum || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{inAlbumView ? (
<button
className={`${btn} text-amber-300/90 hover:bg-amber-500/10 hover:text-amber-200`}
onClick={() => void removeFromAlbum(selectedAlbumId, ids)}
>
Remove from album
</button>
) : null}
<div className="h-5 w-px bg-white/10" />
<div className="relative">
<button
className={panel === "delete" ? `${btn} bg-red-500/15 text-red-300` : `${btn} text-gray-300 hover:bg-red-500/10 hover:text-red-300`}
onClick={() => togglePanel("delete")}
disabled={deleting}
title="Delete files from disk"
>
{deleting ? "Deleting…" : "Delete"}
</button>
{panel === "delete" ? (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-64 -translate-x-1/2 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
>
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setPanel(null)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200 disabled:opacity-50"
onClick={() => void handleDelete()}
disabled={deleting}
>
{deleting ? "Deleting…" : `Delete ${selectedCount} from disk`}
</button>
</div>
</div>
) : null}
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={clearGallerySelection}
title="Clear selection"
>
<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>
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
import { Tooltip } from "./Tooltip";
type Rgb = [number, number, number];
// Representative colors for the quick-pick swatches. Each is just an RGB the
// distance filter matches against — not a hard bucket.
const SWATCHES: { name: string; rgb: Rgb }[] = [
{ name: "Red", rgb: [226, 59, 59] },
{ name: "Orange", rgb: [232, 134, 46] },
{ name: "Yellow", rgb: [242, 207, 46] },
{ name: "Green", rgb: [76, 175, 80] },
{ name: "Teal", rgb: [31, 182, 166] },
{ name: "Blue", rgb: [59, 125, 216] },
{ name: "Purple", rgb: [139, 92, 246] },
{ name: "Pink", rgb: [236, 72, 153] },
{ name: "Brown", rgb: [139, 90, 43] },
{ name: "Black", rgb: [26, 26, 26] },
{ name: "White", rgb: [245, 245, 245] },
{ name: "Gray", rgb: [154, 160, 166] },
];
function rgbEquals(a: Rgb | null, b: Rgb): boolean {
return a !== null && a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}
function toHex([r, g, b]: Rgb): string {
return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
}
function fromHex(hex: string): Rgb {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function ColorFilter() {
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const colorBackfill = useGalleryStore((state) => state.colorBackfill);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const isActive = colorFilter !== null;
const isCustom = isActive && !SWATCHES.some((swatch) => rgbEquals(colorFilter, swatch.rgb));
// Collapse the panel when clicking elsewhere.
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [open]);
return (
<div ref={ref} className="relative ml-1 flex shrink-0 items-center border-l border-white/[0.06] pl-2">
{/* Trigger — a single palette icon; shows the active color as a dot when a
filter is applied so the collapsed state still communicates it. */}
<Tooltip label={isActive ? "Color filter active" : "Filter by color"} delay={400}>
<button
className={`relative flex items-center gap-1.5 rounded-lg px-2 py-1.5 transition-colors ${
open || isActive ? "bg-white/10 text-white" : "text-gray-500 hover:bg-white/5 hover:text-gray-200"
}`}
onClick={() => setOpen((value) => !value)}
aria-label="Filter by color"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M12 3a9 9 0 100 18c.83 0 1.5-.67 1.5-1.5 0-.39-.15-.74-.39-1.01-.23-.26-.39-.61-.39-1 0-.83.67-1.5 1.5-1.5H16a5 5 0 005-5c0-4.42-4.03-8-9-8z" />
<circle cx="7.5" cy="11.5" r="1" fill="currentColor" stroke="none" />
<circle cx="11.5" cy="7.5" r="1" fill="currentColor" stroke="none" />
<circle cx="15.5" cy="9.5" r="1" fill="currentColor" stroke="none" />
</svg>
{isActive ? (
<span
className="h-3 w-3 rounded-full border border-white/30"
style={{ backgroundColor: toHex(colorFilter as Rgb) }}
/>
) : null}
</button>
</Tooltip>
<AnimatePresence initial={false}>
{open ? (
// Right-aligned popover so it never widens the toolbar row or gets
// pushed off-screen on narrow windows. Swatches wrap into a compact
// grid instead of a single long horizontal strip.
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.14, ease: "easeOut" }}
className="absolute right-0 top-full z-30 mt-2 w-max rounded-xl border border-white/10 bg-gray-950/98 p-2.5 shadow-2xl backdrop-blur light-theme:border-gray-700/50"
>
<div className="grid grid-cols-7 gap-1.5">
{SWATCHES.map((swatch) => {
const active = rgbEquals(colorFilter, swatch.rgb);
return (
<button
key={swatch.name}
title={swatch.name}
aria-label={`Filter by ${swatch.name}`}
className={`h-5 w-5 shrink-0 rounded-full border transition-transform ${
active ? "scale-110 border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={{ backgroundColor: toHex(swatch.rgb) }}
onClick={() => setColorFilter(active ? null : swatch.rgb)}
/>
);
})}
{/* Custom color picker — rainbow until a custom color is chosen. */}
<label
title="Custom color"
className={`relative h-5 w-5 shrink-0 cursor-pointer overflow-hidden rounded-full border ${
isCustom ? "border-white/40 ring-2 ring-white/70" : "border-white/15 hover:scale-110"
}`}
style={
isCustom
? { backgroundColor: toHex(colorFilter as Rgb) }
: { background: "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)" }
}
>
<input
type="color"
className="absolute inset-0 cursor-pointer opacity-0"
value={colorFilter ? toHex(colorFilter) : "#3b7dd8"}
onChange={(event) => setColorFilter(fromHex(event.target.value))}
/>
</label>
</div>
{isActive || (colorBackfill && colorBackfill.total > 0) ? (
<div className="mt-2 flex items-center justify-between gap-3 border-t border-white/[0.06] pt-2 light-theme:border-gray-700/40">
{colorBackfill && colorBackfill.total > 0 ? (
<span
className="text-[10px] text-gray-600"
title="Sampling colors from existing thumbnails — color search fills in as this runs"
>
sampling {colorBackfill.processed.toLocaleString()}/{colorBackfill.total.toLocaleString()}
</span>
) : <span />}
{isActive ? (
<button
className="shrink-0 rounded px-1 text-[11px] text-gray-500 transition-colors hover:text-gray-200"
onClick={() => setColorFilter(null)}
title="Clear color filter"
>
Clear
</button>
) : null}
</div>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
);
}
+31
View File
@@ -42,6 +42,7 @@ const DEMO_UPDATE_VERSION = "0.2.0";
export function DemoPanel() { export function DemoPanel() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const appVersion = useGalleryStore((state) => state.appVersion);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const downloadTimer = useRef<number | null>(null); const downloadTimer = useRef<number | null>(null);
@@ -160,6 +161,18 @@ export function DemoPanel() {
}); });
}; };
// --- What's New flow ------------------------------------------------------
// Drives the post-update greeting without needing a real version change. The
// toast + modal pull their content from the bundled changelog for the running
// app version, so the current release's notes are what render.
const showWhatsNewToast = () =>
useGalleryStore.setState({ whatsNewToast: appVersion ?? DEMO_UPDATE_VERSION, whatsNewOpen: false });
const openWhatsNewModal = () => useGalleryStore.setState({ whatsNewOpen: true, whatsNewToast: null });
const resetWhatsNew = () => useGalleryStore.setState({ whatsNewOpen: false, whatsNewToast: null });
if (!open) return null; if (!open) return null;
const injectBtn = const injectBtn =
@@ -215,6 +228,24 @@ export function DemoPanel() {
Reset updater state Reset updater state
</button> </button>
</div> </div>
<div className="my-3 h-px bg-amber-400/20" />
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-200/60">What's new</p>
<p className="mb-2 text-[11px] leading-snug text-amber-200/70">
Post-update greeting for v{appVersion ?? "—"}, sourced from the bundled changelog.
</p>
<div className="flex flex-col gap-1.5">
<button className={injectBtn} onClick={showWhatsNewToast}>
Show "What's new" toast
</button>
<button className={injectBtn} onClick={openWhatsNewModal}>
Open "What's new" modal
</button>
<button className={neutralBtn} onClick={resetWhatsNew}>
Reset What's New state
</button>
</div>
</div> </div>
); );
} }
+85 -18
View File
@@ -1,4 +1,5 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { DuplicateGroup, useGalleryStore } from "../store"; import { DuplicateGroup, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
@@ -71,7 +72,7 @@ function DuplicateGroupCard({ group }: { group: DuplicateGroup }) {
return ( return (
<button <button
key={image.id} key={image.id}
className={`group relative overflow-hidden rounded-xl border transition-all ${ className={`media-dark-surface group relative overflow-hidden rounded-xl border transition-all ${
isSelected isSelected
? "border-red-400/50 ring-1 ring-red-400/30" ? "border-red-400/50 ring-1 ring-red-400/30"
: "border-white/8 hover:border-white/20" : "border-white/8 hover:border-white/20"
@@ -128,8 +129,20 @@ export function DuplicateFinder() {
const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates); const deleteSelectedDuplicates = useGalleryStore((state) => state.deleteSelectedDuplicates);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleteResult, setDeleteResult] = useState<string | null>(null); const [deleteResult, setDeleteResult] = useState<string | null>(null);
// Virtualize the group list so a large result set (e.g. thousands of pairs)
// only mounts the on-screen cards. Group cards vary in height (number of
// copies wraps across rows), so heights are measured dynamically.
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: duplicateGroups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 220,
overscan: 4,
});
const selectedCount = duplicateSelectedIds.size; const selectedCount = duplicateSelectedIds.size;
const hasResults = duplicateGroups.length > 0; const hasResults = duplicateGroups.length > 0;
const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null); const hasScanned = hasResults || duplicateLastScanned !== null || (!duplicateScanning && duplicateScanProgress !== null);
@@ -141,6 +154,7 @@ export function DuplicateFinder() {
const handleDelete = async () => { const handleDelete = async () => {
setDeleting(true); setDeleting(true);
setConfirmingDelete(false);
setDeleteResult(null); setDeleteResult(null);
try { try {
const deleted = await deleteSelectedDuplicates(); const deleted = await deleteSelectedDuplicates();
@@ -165,7 +179,7 @@ export function DuplicateFinder() {
: null; : null;
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#07080f]"> <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-gray-950">
{/* Header */} {/* Header */}
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4"> <div className="shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
@@ -193,7 +207,7 @@ export function DuplicateFinder() {
{/* Batch select — only shown when there are groups and nothing is selected yet */} {/* Batch select — only shown when there are groups and nothing is selected yet */}
{hasResults && selectedCount === 0 && !deleting && ( {hasResults && selectedCount === 0 && !deleting && (
<button <button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white" className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={selectKeepFirstAllGroups} onClick={selectKeepFirstAllGroups}
title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`} title={`Mark ${totalDuplicateImages} duplicate${totalDuplicateImages === 1 ? "" : "s"} for deletion across all groups (keeps first in each)`}
> >
@@ -204,23 +218,58 @@ export function DuplicateFinder() {
<> <>
<span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span> <span className="text-[11px] text-white/40">{selectedCount} marked for deletion</span>
<button <button
className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40" className="rounded-lg border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.07] hover:text-white disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={clearDuplicateSelection} onClick={clearDuplicateSelection}
disabled={deleting} disabled={deleting}
> >
Deselect all Deselect all
</button> </button>
<button <div className="relative">
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" <button
onClick={handleDelete} 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"
disabled={deleting} onClick={() => setConfirmingDelete((v) => !v)}
> disabled={deleting}
{deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`} >
</button> {deleting ? "Deleting…" : `Delete ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</button>
{confirmingDelete && !deleting ? (
<>
{/* Click-away backdrop */}
<div className="fixed inset-0 z-40" onClick={() => setConfirmingDelete(false)} />
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-red-500/30 bg-gray-950/98 p-3 text-left shadow-2xl backdrop-blur">
<div className="mb-1 flex items-center gap-1.5 text-red-300">
<svg className="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
<p className="text-xs font-semibold">Delete from disk</p>
</div>
<p className="mb-2.5 text-[11px] leading-relaxed text-gray-400">
Permanently delete {selectedCount} file{selectedCount === 1 ? "" : "s"} from your computer.
This removes the actual file{selectedCount === 1 ? "" : "s"} from disk and cannot be undone.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2.5 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={handleDelete}
>
Delete {selectedCount} from disk
</button>
</div>
</div>
</>
) : null}
</div>
</> </>
) : null} ) : null}
<button <button
className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40" className="rounded-lg border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }} onClick={() => { setDeleteResult(null); void scanDuplicates(selectedFolderId); }}
disabled={duplicateScanning} disabled={duplicateScanning}
> >
@@ -277,11 +326,29 @@ export function DuplicateFinder() {
<p className="text-sm text-white/25">No duplicate files found.</p> <p className="text-sm text-white/25">No duplicate files found.</p>
</div> </div>
) : ( ) : (
<div className="overflow-y-auto px-6 py-5"> <div ref={scrollRef} className="overflow-y-auto px-6 py-5">
<div className="space-y-4"> <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{duplicateGroups.map((group) => ( {virtualizer.getVirtualItems().map((virtualItem) => {
<DuplicateGroupCard key={group.file_hash} group={group} /> const group = duplicateGroups[virtualItem.index];
))} if (!group) return null;
return (
<div
key={group.file_hash}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
paddingBottom: 16,
}}
>
<DuplicateGroupCard group={group} />
</div>
);
})}
</div> </div>
</div> </div>
)} )}
+449
View File
@@ -0,0 +1,449 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { DirEntry, DirListing, FolderAddResult, useGalleryStore } from "../store";
function normalizePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
function folderName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, "");
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
return parts.length > 0 ? parts[parts.length - 1] : path;
}
function buildBreadcrumbs(path: string | null): { label: string; path: string | null }[] {
if (!path) return [{ label: "This PC / Home", path: null }];
const separator = path.includes("\\") ? "\\" : "/";
const normalized = path.replace(/[\\/]+$/, "");
const windowsDrive = normalized.match(/^[A-Za-z]:/);
if (windowsDrive) {
const drive = windowsDrive[0] + "\\";
const rest = normalized.slice(2).split(/[\\/]+/).filter(Boolean);
let current = drive;
return [
{ label: "This PC", path: null },
{ label: drive, path: drive },
...rest.map((part) => {
current = current.endsWith("\\") ? `${current}${part}` : `${current}\\${part}`;
return { label: part, path: current };
}),
];
}
const parts = normalized.split(/[\\/]+/).filter(Boolean);
let current = separator === "/" ? "" : "";
return [
{ label: "/", path: null },
...parts.map((part) => {
current = `${current}/${part}`;
return { label: part, path: current };
}),
];
}
function StatusLine({ results }: { results: FolderAddResult[] | null }) {
if (!results) return null;
const added = results.filter((result) => result.status === "added").length;
const skipped = results.filter((result) => result.status === "skipped").length;
const failed = results.filter((result) => result.status === "error").length;
return (
<p className="text-xs text-gray-500 light-theme:text-gray-600">
Added {added}, skipped {skipped}, failed {failed}.
</p>
);
}
function FolderRow({
entry,
selected,
alreadyAdded,
onToggle,
onNavigate,
}: {
entry: DirEntry;
selected: boolean;
alreadyAdded: boolean;
onToggle: () => void;
onNavigate: () => void;
}) {
return (
<div
className={`group flex h-11 items-center gap-3 rounded-md border px-3 transition-colors ${
alreadyAdded
? "border-transparent bg-white/[0.025] text-gray-600 light-theme:bg-gray-900 light-theme:text-gray-500"
: selected
? "border-white/15 bg-white/[0.085] text-white shadow-[inset_0_0_0_1px_rgb(255_255_255_/_0.035)] light-theme:border-gray-700/45 light-theme:bg-gray-800/55 light-theme:text-white"
: "border-transparent text-gray-300 hover:bg-white/[0.055] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
>
<button
type="button"
className={`grid h-5 w-5 shrink-0 place-items-center rounded border transition-colors ${
selected
? "border-white/30 bg-gray-200 text-gray-950 light-theme:border-gray-700/55 light-theme:bg-gray-700 light-theme:text-white"
: "border-white/15 bg-white/[0.035] text-transparent hover:border-white/30 light-theme:border-gray-700/50 light-theme:bg-gray-950"
} ${alreadyAdded ? "cursor-not-allowed opacity-40" : ""}`}
onClick={onToggle}
disabled={alreadyAdded}
aria-label={selected ? `Remove ${entry.name} from folders to add` : `Choose ${entry.name}`}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={onNavigate}
title={entry.path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<span className="truncate text-sm">{entry.name}</span>
</button>
{alreadyAdded ? (
<span className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[11px] text-gray-500 light-theme:border-gray-700/40 light-theme:bg-gray-950">
Added
</span>
) : null}
<button
type="button"
className="rounded-md p-1 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={onNavigate}
title={entry.has_children ? "Open folder" : "No subfolders"}
>
<svg className={`h-4 w-4 ${entry.has_children ? "" : "opacity-45"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
);
}
function StagedFoldersPanel({
stagedPaths,
onRemove,
onClear,
}: {
stagedPaths: string[];
onRemove: (path: string) => void;
onClear: () => void;
}) {
return (
<aside className="flex min-h-0 w-full flex-col border-t border-white/[0.07] bg-white/[0.018] light-theme:border-gray-300/70 light-theme:bg-gray-900/35 lg:w-80 lg:border-l lg:border-t-0">
<div className="flex items-center justify-between gap-3 border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-300/70">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-gray-500">
Folders to add ({stagedPaths.length})
</p>
{stagedPaths.length > 0 ? (
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={onClear}
>
Clear all
</button>
) : null}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{stagedPaths.length === 0 ? (
<div className="flex h-full min-h-40 flex-col items-center justify-center rounded-md border border-dashed border-white/[0.08] px-5 text-center light-theme:border-gray-700/35">
<p className="text-sm text-gray-500">No folders selected.</p>
<p className="mt-1 max-w-52 text-xs leading-relaxed text-gray-600 light-theme:text-gray-500">
Choose folders on the left and they will collect here.
</p>
</div>
) : (
<div className="space-y-2">
{stagedPaths.map((path) => (
<div
key={path}
className="group flex min-h-12 items-center gap-2 rounded-md border border-white/[0.07] bg-white/[0.045] px-3 py-2 text-gray-200 transition-colors hover:border-white/15 hover:bg-white/[0.07] light-theme:border-gray-700/40 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
title={path}
>
<svg className="h-4 w-4 shrink-0 text-amber-300/90" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 6.5A2.5 2.5 0 015.5 4h4.1l2 2H18.5A2.5 2.5 0 0121 8.5v9A2.5 2.5 0 0118.5 20h-13A2.5 2.5 0 013 17.5v-11z" />
</svg>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{folderName(path)}</p>
<p className="mt-0.5 truncate text-[11px] text-gray-600 light-theme:text-gray-500">{path}</p>
</div>
<button
type="button"
className="rounded-md p-1 text-gray-500 opacity-80 transition-colors hover:bg-white/[0.08] hover:text-white group-hover:opacity-100 light-theme:hover:bg-gray-700"
onClick={() => onRemove(path)}
aria-label={`Remove ${path} from folders to add`}
title="Remove from folders to add"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</aside>
);
}
export function FolderPickerModal() {
const folderPickerOpen = useGalleryStore((state) => state.folderPickerOpen);
const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const folders = useGalleryStore((state) => state.folders);
const listDirectories = useGalleryStore((state) => state.listDirectories);
const addFolders = useGalleryStore((state) => state.addFolders);
const [listing, setListing] = useState<DirListing | null>(null);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const [stagedPaths, setStagedPaths] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<FolderAddResult[] | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const libraryPaths = useMemo(() => new Set(folders.map((folder) => normalizePath(folder.path))), [folders]);
const stagedSet = useMemo(() => new Set(stagedPaths.map(normalizePath)), [stagedPaths]);
const breadcrumbs = useMemo(() => buildBreadcrumbs(listing?.current ?? null), [listing?.current]);
const virtualizer = useVirtualizer({
count: listing?.entries.length ?? 0,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 8,
});
useEffect(() => {
if (!folderPickerOpen) return;
let cancelled = false;
setLoading(true);
setError(null);
void listDirectories(currentPath)
.then((nextListing) => {
if (cancelled) return;
setListing(nextListing);
scrollRef.current?.scrollTo({ top: 0, left: 0 });
})
.catch((loadError) => {
if (cancelled) return;
setListing({ current: currentPath, parent: null, entries: [] });
setError(loadError instanceof Error ? loadError.message : String(loadError));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [currentPath, folderPickerOpen, listDirectories]);
useEffect(() => {
if (!folderPickerOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setFolderPickerOpen(false);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [folderPickerOpen, setFolderPickerOpen]);
useEffect(() => {
if (folderPickerOpen) return;
setCurrentPath(null);
setListing(null);
setStagedPaths([]);
setError(null);
setResults(null);
setAdding(false);
}, [folderPickerOpen]);
if (!folderPickerOpen) return null;
const entries = listing?.entries ?? [];
const togglePath = (path: string) => {
const normalized = normalizePath(path);
if (libraryPaths.has(normalized)) return;
setResults(null);
setStagedPaths((current) => {
const exists = current.some((staged) => normalizePath(staged) === normalized);
return exists ? current.filter((staged) => normalizePath(staged) !== normalized) : [...current, path];
});
};
const removeStagedPath = (path: string) => {
const normalized = normalizePath(path);
setResults(null);
setStagedPaths((current) => current.filter((staged) => normalizePath(staged) !== normalized));
};
const clearStagedPaths = () => {
setResults(null);
setStagedPaths([]);
};
const confirmAdd = async () => {
if (stagedPaths.length === 0 || adding) return;
setAdding(true);
setError(null);
try {
const addResults = await addFolders(stagedPaths);
const failed = addResults.filter((result) => result.status === "error");
setResults(addResults);
if (failed.length > 0) {
setStagedPaths(stagedPaths.filter((_, i) => addResults[i]?.status === "error"));
setError(failed.map((failure) => failure.data).join("; "));
return;
}
setFolderPickerOpen(false);
} catch (addError) {
setError(addError instanceof Error ? addError.message : String(addError));
} finally {
setAdding(false);
}
};
return (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={() => setFolderPickerOpen(false)}
>
<div
className="relative flex h-[min(82vh,760px)] w-[min(90vw,1180px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70"
onClick={(event) => event.stopPropagation()}
>
<header className="border-b border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-start justify-between gap-6">
<div className="min-w-0">
<p className="text-base font-semibold text-white">Add media folders</p>
<p className="mt-1 text-xs text-gray-500 light-theme:text-gray-600">Choose folders from any location, then add them together.</p>
</div>
<button
type="button"
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => setFolderPickerOpen(false)}
title="Close folder picker"
>
<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>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<main className="flex min-h-0 flex-1 flex-col px-5 py-4">
<div className="mb-4 flex items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-2.5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-40 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setCurrentPath(listing?.parent ?? null)}
disabled={!listing?.current}
>
Up
</button>
<nav className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden rounded-md border border-white/10 bg-white/[0.025] px-2 py-1.5 light-theme:border-gray-300/70 light-theme:bg-gray-900">
{breadcrumbs.map((crumb, index) => (
<span key={`${crumb.path ?? "root"}-${index}`} className="flex min-w-0 items-center gap-1">
{index > 0 ? <span className="text-gray-700 light-theme:text-gray-400">/</span> : null}
<button
type="button"
className="max-w-40 truncate rounded px-1.5 py-0.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:text-gray-500 light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setCurrentPath(crumb.path)}
title={crumb.path ?? "Roots"}
>
{crumb.label}
</button>
</span>
))}
</nav>
</div>
{error ? (
<div className="mb-3 rounded-md border border-amber-400/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-200 light-theme:border-amber-600/40 light-theme:bg-amber-100 light-theme:text-amber-800">
{error}
</div>
) : null}
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto rounded-md border border-white/[0.07] bg-white/[0.018] p-2 light-theme:border-gray-300/70 light-theme:bg-gray-900/50">
{loading ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">Loading folders...</div>
) : entries.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-gray-500">No folders found here.</div>
) : (
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const entry = entries[virtualItem.index];
const normalized = normalizePath(entry.path);
return (
<div
key={virtualItem.key}
className="absolute left-0 top-0 w-full px-0.5"
style={{
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<FolderRow
entry={entry}
selected={stagedSet.has(normalized)}
alreadyAdded={libraryPaths.has(normalized)}
onToggle={() => togglePath(entry.path)}
onNavigate={() => setCurrentPath(entry.path)}
/>
</div>
);
})}
</div>
)}
</div>
</main>
<StagedFoldersPanel
stagedPaths={stagedPaths}
onRemove={removeStagedPath}
onClear={clearStagedPaths}
/>
</div>
<footer className="border-t border-white/[0.07] px-5 py-4 light-theme:border-gray-200">
<div className="flex items-end justify-between gap-4">
<div className="min-w-0 flex-1">
<StatusLine results={results} />
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
className="rounded-md border border-white/10 bg-white/[0.035] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.07] hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => setFolderPickerOpen(false)}
>
Cancel
</button>
<button
type="button"
className="rounded-md border border-white/15 bg-white/[0.08] px-3 py-1.5 text-xs text-white transition-colors hover:bg-white/[0.12] disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800"
onClick={() => void confirmAdd()}
disabled={stagedPaths.length === 0 || adding}
>
{adding ? "Adding..." : `Add ${stagedPaths.length}`}
</button>
</div>
</div>
</footer>
</div>
</div>
);
}
+9 -9
View File
@@ -33,13 +33,13 @@ export function FolderScopeDropdown() {
}; };
return ( return (
<div ref={ref} className="relative"> <div ref={ref} className="feature-scope-dropdown relative">
<button <button
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={`flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ className={`feature-scope-trigger flex max-w-56 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open open
? "border-white/15 bg-white/8 text-white" ? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200" : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
title="Change folder scope" title="Change folder scope"
> >
@@ -55,10 +55,10 @@ export function FolderScopeDropdown() {
</svg> </svg>
</button> </button>
{open ? ( {open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"> <div className="feature-scope-menu absolute right-0 top-full z-30 mt-1.5 max-h-80 min-w-52 overflow-y-auto rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
<button <button
className={`flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${ className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
selectedFolderId === null ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white" selectedFolderId === null ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={() => select(null)} onClick={() => select(null)}
> >
@@ -74,8 +74,8 @@ export function FolderScopeDropdown() {
return ( return (
<button <button
key={folder.id} 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 ${ className={`feature-scope-option flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors ${
active ? "bg-white/6 text-white" : "text-gray-400 hover:bg-white/5 hover:text-white" active ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={() => select(folder.id)} onClick={() => select(folder.id)}
> >
+163 -67
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useCallback, useState } from "react"; import { useEffect, useLayoutEffect, useRef, useCallback, useMemo, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store"; import { ImageRecord, parseSearchValue, tileSizeForZoom, useGalleryStore } from "../store";
import { BulkActionBar } from "./BulkActionBar";
import { Tooltip } from "./Tooltip";
const GAP = 6; const GAP = 6;
@@ -29,8 +32,7 @@ export 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 findSimilar = useGalleryStore((state) => state.findSimilar);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
return ( return (
@@ -58,9 +60,9 @@ export function ContextMenu({
? "text-gray-200 hover:bg-white/5 hover:text-white" ? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed" : "text-gray-600 cursor-not-allowed"
}`} }`}
onClick={async () => { onClick={() => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id); findSimilar(image.id, image.folder_id);
onClose(); onClose();
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
@@ -111,28 +113,70 @@ export function ImageTile({
}: { }: {
image: ImageRecord; image: ImageRecord;
onClick: () => void; onClick: () => void;
onContextMenu: (event: React.MouseEvent<HTMLButtonElement>) => void; onContextMenu: (event: React.MouseEvent<HTMLDivElement>) => void;
}) { }) {
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 findSimilar = useGalleryStore((state) => state.findSimilar);
const similarScope = useGalleryStore((state) => state.similarScope); const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
const src = image.thumbnail_path const src = image.thumbnail_path ? convertFileSrc(image.thumbnail_path) : null;
? convertFileSrc(image.thumbnail_path)
: image.media_kind === "image" && image.path
? convertFileSrc(image.path)
: null;
return ( return (
<button <Tooltip label={image.filename} delay={500} block followCursor>
className="group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none" <div
className={`media-dark-surface group relative overflow-hidden rounded-xl bg-white/[0.04] text-left focus:outline-none transition-shadow ${
selected ? "ring-2 ring-inset ring-blue-400/80" : ""
}`}
style={{ width: "100%", aspectRatio: "1 / 1" }} style={{ width: "100%", aspectRatio: "1 / 1" }}
onClick={onClick}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
title={image.filename}
> >
{/* Full-tile click target — opens, or toggles selection while selecting.
A real button (over the non-interactive tile div) keeps it keyboard-
accessible without nesting buttons. */}
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
aria-label={`Open ${image.filename}`}
onClick={(event) => {
event.stopPropagation();
if (selectionActive) toggleGallerySelected(image.id);
else onClick();
}}
onDoubleClick={(event) => {
event.stopPropagation();
onClick();
}}
/>
{/* Selection corner — a top-left zone that reveals the checkbox only when
hovered (not the whole tile) and toggles selection on click. The
checkbox stays visible once the item is selected. */}
<button
type="button"
role="checkbox"
aria-checked={selected}
aria-label={selected ? "Deselect" : "Select"}
className="group/cb absolute top-0 left-0 z-20 h-11 w-11 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
toggleGallerySelected(image.id);
}}
>
<div
className={`absolute top-2 left-2 flex h-5 w-5 items-center justify-center rounded-full border transition-all duration-150 ${
selected
? "border-blue-400 bg-blue-500 text-white opacity-100"
: "border-white/70 bg-black/40 text-transparent opacity-0 backdrop-blur-sm group-hover/cb:opacity-100"
}`}
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</button>
{/* Image / placeholder */} {/* Image / placeholder */}
{src && !errored ? ( {src && !errored ? (
<> <>
@@ -176,6 +220,17 @@ export function ImageTile({
{/* Persistent badges — only shown when meaningful */} {/* Persistent badges — only shown when meaningful */}
<div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none"> <div className="absolute top-2 right-2 flex flex-col items-end gap-1 pointer-events-none">
{image.embedding_status === "failed" && (
<div
className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm"
title={image.embedding_error ?? "Embedding failed"}
>
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
)}
{image.favorite && ( {image.favorite && (
<div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm"> <div className="rounded-full bg-black/50 p-1 text-rose-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20"> <svg className="h-2.5 w-2.5" fill="currentColor" viewBox="0 0 20 20">
@@ -199,21 +254,6 @@ export function ImageTile({
)} )}
</div> </div>
{/* Embedding failed badge — top-left */}
{image.embedding_status === "failed" && (
<div
className="absolute top-2 left-2 pointer-events-none"
title={image.embedding_error ?? "Embedding failed"}
>
<div className="flex items-center gap-1 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-amber-400 backdrop-blur-sm">
<svg className="h-2.5 w-2.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
</div>
)}
{/* Hover overlay — slides up from bottom */} {/* Hover overlay — slides up from bottom */}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" /> <div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none" />
@@ -233,7 +273,7 @@ export function ImageTile({
<span /> <span />
)} )}
<button <button
className={`rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm ${ className={`relative z-20 rounded-md px-2 py-0.5 text-[10px] transition-colors pointer-events-auto backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80 ${
canFindSimilar canFindSimilar
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white" ? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
: "bg-white/5 text-white/30 cursor-not-allowed" : "bg-white/5 text-white/30 cursor-not-allowed"
@@ -241,7 +281,7 @@ export function ImageTile({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id); findSimilar(image.id, image.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
> >
@@ -249,7 +289,8 @@ export function ImageTile({
</button> </button>
</div> </div>
</div> </div>
</button> </div>
</Tooltip>
); );
} }
@@ -268,29 +309,62 @@ export function Gallery() {
const parsedSearch = parseSearchValue(search); const parsedSearch = parseSearchValue(search);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; image: ImageRecord } | null>(null);
useLayoutEffect(() => {
const el = parentRef.current;
if (!el) return;
setContainerWidth(el.clientWidth);
const ro = new ResizeObserver((entries) => {
setContainerWidth(entries[0].contentRect.width);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const tileSize = tileSizeForZoom(zoomPreset);
const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize],
);
const rowCount = Math.ceil(images.length / cols);
const estimateSize = useCallback(() => tileSize + GAP, [tileSize]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize,
overscan: 3,
paddingStart: GAP,
});
useEffect(() => {
virtualizer.measure();
}, [cols, virtualizer]);
useEffect(() => {
parentRef.current?.scrollTo({ top: 0, left: 0 });
}, [galleryScrollResetKey]);
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const element = parentRef.current; const el = parentRef.current;
if (!element) return; if (!el) return;
if (element.scrollTop < 24) return; if (el.scrollTop < 24) return;
const nearBottom = element.scrollTop + element.clientHeight >= element.scrollHeight - 600; const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages();
} }
}, [images.length, loadMoreImages, loadingImages, totalImages]); }, [images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => { useEffect(() => {
const element = parentRef.current; const el = parentRef.current;
if (!element) return; if (!el) return;
element.addEventListener("scroll", handleScroll, { passive: true }); el.addEventListener("scroll", handleScroll, { passive: true });
return () => element.removeEventListener("scroll", handleScroll); return () => el.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;
@@ -308,7 +382,8 @@ export function Gallery() {
}, []); }, []);
return ( return (
<div ref={parentRef} className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]"> <div className="relative flex-1 min-h-0">
<div ref={parentRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-gray-950">
{images.length === 0 && loadingImages ? ( {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="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">
@@ -365,25 +440,41 @@ export function Gallery() {
</div> </div>
</div> </div>
) : ( ) : (
<div <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
className="grid content-start" {virtualizer.getVirtualItems().map((virtualRow) => {
style={{ const startIndex = virtualRow.index * cols;
padding: GAP, const rowImages = images.slice(startIndex, startIndex + cols);
gap: GAP, return (
gridTemplateColumns: `repeat(auto-fill, minmax(${tileSizeForZoom(zoomPreset)}px, 1fr))`, <div
}} key={virtualRow.key}
> style={{
{images.map((image) => ( position: "absolute",
<ImageTile top: virtualRow.start,
key={image.id} width: "100%",
image={image} height: virtualRow.size,
onClick={() => openImage(image)} display: "grid",
onContextMenu={(event) => { gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
event.preventDefault(); gap: GAP,
setContextMenu({ x: event.clientX, y: event.clientY, image }); paddingLeft: GAP,
}} paddingRight: GAP,
/> paddingBottom: GAP,
))} boxSizing: "border-box",
}}
>
{rowImages.map((image) => (
<ImageTile
key={image.id}
image={image}
onClick={() => openImage(image)}
onContextMenu={(event) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, image });
}}
/>
))}
</div>
);
})}
</div> </div>
)} )}
@@ -402,5 +493,10 @@ export function Gallery() {
/> />
) : null} ) : null}
</div> </div>
{/* Pinned to the bottom of the gallery viewport — outside the scroll
container so it stays put while the grid scrolls. */}
<BulkActionBar />
</div>
); );
} }
+179 -16
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, invoke } from "@tauri-apps/api/core";
import { useGalleryStore, ImageTag, AiRating } from "../store"; import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { useGalleryStore, ImageTag, ImageExif, AiRating } from "../store";
import { VideoPlayer } from "./VideoPlayer"; import { VideoPlayer } from "./VideoPlayer";
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@@ -143,15 +144,18 @@ export function Lightbox() {
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 findSimilar = useGalleryStore((state) => state.findSimilar);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion); const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags); const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag); const addUserTag = useGalleryStore((state) => state.addUserTag);
const removeTag = useGalleryStore((state) => state.removeTag); const removeTag = useGalleryStore((state) => state.removeTag);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage); const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const getImageExif = useGalleryStore((state) => state.getImageExif);
// Tracks the image id that is currently displayed, used to discard async // Tracks the image id that is currently displayed, used to discard async
// tag mutations that resolve after the user has navigated to another image. // tag mutations that resolve after the user has navigated to another image.
@@ -163,12 +167,17 @@ export function Lightbox() {
const [isPanning, setIsPanning] = useState(false); const [isPanning, setIsPanning] = useState(false);
const lastPanPointRef = useRef({ x: 0, y: 0 }); const lastPanPointRef = useRef({ x: 0, y: 0 });
const [imageTags, setImageTags] = useState<ImageTag[]>([]); const [imageTags, setImageTags] = useState<ImageTag[]>([]);
const [imageExif, setImageExif] = useState<ImageExif | null>(null);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [tagAdding, setTagAdding] = useState(false); const [tagAdding, setTagAdding] = useState(false);
const [tagsExpanded, setTagsExpanded] = useState(false); const [tagsExpanded, setTagsExpanded] = useState(false);
const [taggingQueued, setTaggingQueued] = useState(false); const [taggingQueued, setTaggingQueued] = useState(false);
// Region selection state // Region selection state
const [albumMenuOpen, setAlbumMenuOpen] = useState(false);
const [albumAddedTo, setAlbumAddedTo] = useState<number | null>(null);
const [newAlbumName, setNewAlbumName] = useState("");
const [albumAdding, setAlbumAdding] = useState(false);
const [regionSelectMode, setRegionSelectMode] = useState(false); const [regionSelectMode, setRegionSelectMode] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dragRect, setDragRect] = useState<DragRect | null>(null); const [dragRect, setDragRect] = useState<DragRect | null>(null);
@@ -214,6 +223,7 @@ export function Lightbox() {
useEffect(() => { useEffect(() => {
setView(IDENTITY_VIEW); setView(IDENTITY_VIEW);
setImageTags([]); setImageTags([]);
setImageExif(null);
setTagInput(""); setTagInput("");
setTagsExpanded(false); setTagsExpanded(false);
setTaggingQueued(false); setTaggingQueued(false);
@@ -232,6 +242,20 @@ export function Lightbox() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]); }, [selectedImage?.id, selectedImage?.ai_tagged_at, getImageTags]);
// EXIF is read on demand from the file (not stored), so it works on every
// already-indexed image without a reindex. Only meaningful for images.
useEffect(() => {
if (!selectedImage || selectedImage.media_kind !== "image") {
setImageExif(null);
return;
}
let cancelled = false;
void getImageExif(selectedImage.id)
.then((exif) => { if (!cancelled) setImageExif(exif); })
.catch(() => { if (!cancelled) setImageExif(null); });
return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
// Reset the queued state once the worker finishes so the button is usable again // Reset the queued state once the worker finishes so the button is usable again
useEffect(() => { useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false); if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
@@ -360,12 +384,10 @@ export function Lightbox() {
exitRegionMode(); exitRegionMode();
setRegionSearching(true); setRegionSearching(true);
const folderId = void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
.finally(() => setRegionSearching(false)); .finally(() => setRegionSearching(false));
}, },
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode], [isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode],
); );
// Build the CSS rect for the selection overlay (viewport-relative) // Build the CSS rect for the selection overlay (viewport-relative)
@@ -377,7 +399,7 @@ export function Lightbox() {
{selectedImage ? ( {selectedImage ? (
<motion.div <motion.div
key="lightbox" key="lightbox"
className="fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm" className="media-dark-surface fixed inset-0 z-50 flex bg-black/90 backdrop-blur-sm"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
@@ -495,7 +517,7 @@ export function Lightbox() {
</AnimatePresence> </AnimatePresence>
</div> </div>
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95"> <div className="lightbox-panel flex w-64 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 lg:w-72">
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4"> <div className="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>
@@ -512,18 +534,25 @@ export function Lightbox() {
</svg> </svg>
</button> </button>
<button <button
className={`rounded-full border px-3 py-1.5 text-xs ${ className={`flex items-center gap-1.5 rounded-full border px-2 py-1.5 text-xs lg:px-3 ${
canFindSimilar canFindSimilar
? "border-white/10 bg-white/5 text-gray-300 hover:text-white" ? "border-white/10 bg-white/5 text-gray-300 hover:text-white"
: "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed" : "border-white/5 bg-white/[0.03] text-gray-600 cursor-not-allowed"
}`} }`}
onClick={() => { onClick={() => {
if (!canFindSimilar) return; if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id); void findSimilar(selectedImage.id, selectedImage.folder_id);
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
title={canFindSimilar ? "Find similar images" : "Embeddings not ready"}
> >
{canFindSimilar ? "Similar" : "Embeddings not ready"} <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M13 3l1.55 4.65L19 9.2l-4.45 1.55L13 15.4l-1.55-4.65L7 9.2l4.45-1.55L13 3z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.6}
d="M5.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2L2.5 17.5l2.2-.8.8-2.2z" />
</svg>
<span className="hidden lg:inline">{canFindSimilar ? "Similar" : "Embeddings not ready"}</span>
</button> </button>
</div> </div>
<button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}> <button className="rounded p-1 text-gray-400 hover:text-white" onClick={closeImage}>
@@ -779,8 +808,142 @@ export function Lightbox() {
</form> </form>
</div> </div>
<div className="relative">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Albums</p>
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => { setAlbumMenuOpen((v) => !v); setAlbumAddedTo(null); }}
>
Add to album
</button>
</div>
{albumMenuOpen ? (
<div className="rounded-lg border border-white/10 bg-white/[0.03] p-1.5">
<div className="max-h-40 overflow-y-auto">
{albums.length === 0 ? (
<p className="px-2 py-1.5 text-[11px] text-gray-600">No albums yet create one below.</p>
) : (
albums.map((album) => (
<button
key={album.id}
className="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1 text-left text-xs text-gray-300 transition-colors hover:bg-white/5 hover:text-white"
onClick={() => {
if (albumAdding) return;
setAlbumAdding(true);
void addToAlbum(album.id, [selectedImage.id])
.then(() => setAlbumAddedTo(album.id))
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
disabled={albumAdding}
>
<span className="truncate">{album.name}</span>
{albumAddedTo === album.id ? (
<span className="shrink-0 text-[10px] text-emerald-400">Added</span>
) : (
<span className="shrink-0 text-[10px] text-gray-600">{album.image_count}</span>
)}
</button>
))
)}
</div>
<form
className="mt-1 flex gap-1 border-t border-white/[0.06] pt-1.5"
onSubmit={(e) => {
e.preventDefault();
const name = newAlbumName.trim();
if (!name || albumAdding) return;
setAlbumAdding(true);
void createAlbum(name)
.then(async (album) => {
await addToAlbum(album.id, [selectedImage.id]);
setAlbumAddedTo(album.id);
setNewAlbumName("");
})
.catch(() => undefined)
.finally(() => setAlbumAdding(false));
}}
>
<input
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
placeholder="New album…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={albumAdding}
/>
<button
type="submit"
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
disabled={albumAdding || !newAlbumName.trim()}
>
Add
</button>
</form>
</div>
) : null}
</div>
{imageExif &&
(imageExif.make ||
imageExif.model ||
imageExif.lens ||
imageExif.f_number ||
imageExif.exposure_time ||
imageExif.iso ||
imageExif.focal_length ||
(imageExif.gps_lat != null && imageExif.gps_lon != null)) ? (
<div>
<p className="mb-2 text-xs uppercase tracking-wider text-gray-500">Camera</p>
<div className="space-y-1.5">
{imageExif.make || imageExif.model ? (
<p className="text-sm text-white">
{[imageExif.make, imageExif.model].filter(Boolean).join(" ")}
</p>
) : null}
{imageExif.lens ? <p className="text-xs text-gray-400">{imageExif.lens}</p> : null}
{imageExif.f_number || imageExif.exposure_time || imageExif.iso || imageExif.focal_length ? (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-400">
{imageExif.f_number ? <span>{imageExif.f_number}</span> : null}
{imageExif.exposure_time ? <span>{imageExif.exposure_time}</span> : null}
{imageExif.iso ? <span>ISO {imageExif.iso}</span> : null}
{imageExif.focal_length ? <span>{imageExif.focal_length}</span> : null}
</div>
) : null}
{imageExif.gps_lat != null && imageExif.gps_lon != null ? (
<button
className="inline-flex items-center gap-1 text-xs text-sky-400 transition-colors hover:text-sky-300"
title="Open location in your browser"
onClick={() =>
void invoke("open_map_location", {
params: { lat: imageExif.gps_lat, lon: imageExif.gps_lon },
})
}
>
{imageExif.gps_lat.toFixed(5)}, {imageExif.gps_lon.toFixed(5)}
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
) : null}
</div>
</div>
) : null}
<div> <div>
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Path</p> <div className="mb-1 flex items-center justify-between">
<p className="text-xs uppercase tracking-wider text-gray-500">Path</p>
<button
className="rounded p-0.5 text-gray-600 transition-colors hover:text-gray-300"
title="Reveal in Explorer"
onClick={() => void revealItemInDir(selectedImage.path)}
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</button>
</div>
<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>
@@ -793,7 +956,7 @@ export function Lightbox() {
</div> </div>
<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-72 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20 disabled:opacity-20 lg:right-80"
disabled={currentIndex >= images.length - 1 || regionSelectMode} disabled={currentIndex >= images.length - 1 || regionSelectMode}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
+3 -7
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { MediaFilter, ZoomPreset, useGalleryStore } from "../store"; import { MediaFilter, ZoomPreset, useGalleryStore } from "../store";
type MenuKey = "library" | "view" | "filter"; type MenuKey = "library" | "view" | "filter";
@@ -72,7 +71,7 @@ const FILTER_OPTIONS: { value: MediaFilter; label: string }[] = [
export function MenuBar() { export function MenuBar() {
const [openMenu, setOpenMenu] = useState<MenuKey | null>(null); const [openMenu, setOpenMenu] = useState<MenuKey | null>(null);
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const addFolder = useGalleryStore((state) => state.addFolder); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const reindexFolder = useGalleryStore((state) => state.reindexFolder); const reindexFolder = useGalleryStore((state) => state.reindexFolder);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
@@ -93,11 +92,8 @@ export function MenuBar() {
return () => window.removeEventListener("pointerdown", handlePointerDown); return () => window.removeEventListener("pointerdown", handlePointerDown);
}, []); }, []);
const handleAddFolder = async () => { const handleAddFolder = () => {
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); setFolderPickerOpen(true);
if (selected && typeof selected === "string") {
await addFolder(selected);
}
setOpenMenu(null); setOpenMenu(null);
}; };
+154 -25
View File
@@ -1,12 +1,14 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store";
import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog";
type SettingsSection = "workspace" | "general"; type SettingsSection = "workspace" | "general";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
{ id: "general", label: "General", detail: "App data and diagnostics" }, { id: "general", label: "General", detail: "App data and diagnostics" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
]; ];
function formatBytesShort(bytes: number): string { function formatBytesShort(bytes: number): string {
@@ -18,9 +20,9 @@ function formatBytesShort(bytes: number): string {
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
const className = const className =
tone === "ready" tone === "ready"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" ? "border-emerald-400/25 bg-emerald-500/10 text-emerald-300 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: tone === "busy" : tone === "busy"
? "border-sky-400/25 bg-sky-500/10 text-sky-300" ? "border-sky-400/25 bg-sky-500/10 text-sky-300 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700"
: "border-white/10 bg-white/[0.04] text-gray-500"; : "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>; return <span className={`inline-flex rounded-md border px-2 py-0.5 text-[11px] font-medium ${className}`}>{children}</span>;
@@ -88,8 +90,8 @@ function ScopeButton({ scope, current, onSelect, children }: {
type="button" type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={() => onSelect(scope)} onClick={() => onSelect(scope)}
> >
@@ -110,8 +112,8 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
type="button" type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${ className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
active active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200" ? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200" : "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={() => onSelect(acceleration)} onClick={() => onSelect(acceleration)}
> >
@@ -121,7 +123,7 @@ function TaggerAccelerationButton({ acceleration, current, onSelect, children }:
} }
export function SettingsModal() { export function SettingsModal() {
const [activeSection, setActiveSection] = useState<SettingsSection>("workspace"); const [activeSection, setActiveSection] = useState<SettingsSection>("general");
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null); const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false);
@@ -137,6 +139,8 @@ export function SettingsModal() {
const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null); const [dbInfo, setDbInfo] = useState<DatabaseInfo | null>(null);
const [vacuuming, setVacuuming] = useState(false); const [vacuuming, setVacuuming] = useState(false);
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null); const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
const [rebuildingIndex, setRebuildingIndex] = useState(false);
const [rebuildIndexResult, setRebuildIndexResult] = useState<string | null>(null);
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null); const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
const [cleaningThumbnails, setCleaningThumbnails] = useState(false); const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null); const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
@@ -183,15 +187,25 @@ export function SettingsModal() {
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo);
const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase);
const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex);
const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo);
const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails);
const appVersion = useGalleryStore((state) => state.appVersion); const appVersion = useGalleryStore((state) => state.appVersion);
const buildVariant = useGalleryStore((state) => state.buildVariant);
const updateStatus = useGalleryStore((state) => state.updateStatus); const updateStatus = useGalleryStore((state) => state.updateStatus);
const updateVersion = useGalleryStore((state) => state.updateVersion); const updateVersion = useGalleryStore((state) => state.updateVersion);
const updateProgress = useGalleryStore((state) => state.updateProgress);
const updateError = useGalleryStore((state) => state.updateError); const updateError = useGalleryStore((state) => state.updateError);
const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates);
const installUpdate = useGalleryStore((state) => state.installUpdate); const installUpdate = useGalleryStore((state) => state.installUpdate);
const openWhatsNew = useGalleryStore((state) => state.openWhatsNew);
const openOnboarding = useGalleryStore((state) => state.openOnboarding); const openOnboarding = useGalleryStore((state) => state.openOnboarding);
const theme = useGalleryStore((state) => state.theme);
const setTheme = useGalleryStore((state) => state.setTheme);
const lightboxAutoplay = useGalleryStore((state) => state.lightboxAutoplay);
const setLightboxAutoplay = useGalleryStore((state) => state.setLightboxAutoplay);
const lightboxAutoMute = useGalleryStore((state) => state.lightboxAutoMute);
const setLightboxAutoMute = useGalleryStore((state) => state.setLightboxAutoMute);
useEffect(() => { useEffect(() => {
if (!settingsOpen) return; if (!settingsOpen) return;
@@ -309,7 +323,7 @@ export function SettingsModal() {
return ( 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="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div <div
className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60" className="relative flex h-[min(85vh,900px)] w-[min(85vw,1400px)] overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]"> <aside className="flex w-64 shrink-0 flex-col border-r border-white/[0.07] bg-white/[0.025]">
@@ -368,14 +382,14 @@ export function SettingsModal() {
{taggerReady ? ( {taggerReady ? (
<> <>
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void probeTaggerRuntime()} onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking} disabled={taggerRuntimeChecking}
> >
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"} {taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button> </button>
<button <button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
onClick={() => void deleteTaggerModel()} onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
@@ -384,7 +398,7 @@ export function SettingsModal() {
</> </>
) : ( ) : (
<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" className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()} onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
@@ -397,7 +411,7 @@ export function SettingsModal() {
<SettingsItem label="Tagger acceleration" description="Use DirectML when available, or fall back to CPU for reliability."> <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 flex-col items-end gap-1.5">
<div className="flex rounded-lg border border-white/[0.07] p-0.5"> <div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["auto", "directml", "cpu"] as const).map((acceleration) => ( {(["auto", "directml", "cpu"] as const).map((acceleration) => (
<TaggerAccelerationButton <TaggerAccelerationButton
key={acceleration} key={acceleration}
@@ -519,7 +533,7 @@ export function SettingsModal() {
<SettingsGroup title="Queue targets" description="Choose which folders to include when queuing tagging jobs."> <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."> <SettingsItem label="Target scope" description="Queue across the full library, or choose a specific folder set and keep the modal open while you work through it.">
<div className="flex rounded-lg border border-white/[0.07] p-0.5"> <div className="flex rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
<ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton> <ScopeButton scope="all" current={taggingQueueScope} onSelect={setTaggingQueueScope}>All media</ScopeButton>
<ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton> <ScopeButton scope="selected" current={taggingQueueScope} onSelect={setTaggingQueueScope}>Selected folders</ScopeButton>
</div> </div>
@@ -533,14 +547,14 @@ export function SettingsModal() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed" className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))} onClick={() => setTaggingQueueFolderIds(folders.map((folder) => folder.id))}
disabled={taggingQueueScope === "all" || folders.length === 0} disabled={taggingQueueScope === "all" || folders.length === 0}
> >
Select all Select all
</button> </button>
<button <button
className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed" className="rounded-md border border-white/10 bg-white/[0.045] px-2.5 py-1 text-[11px] text-gray-400 transition-colors hover:bg-white/[0.075] hover:text-white disabled:opacity-40 disabled:cursor-not-allowed light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setTaggingQueueFolderIds([])} onClick={() => setTaggingQueueFolderIds([])}
disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0} disabled={taggingQueueScope === "all" || taggingQueueFolderIds.length === 0}
> >
@@ -579,14 +593,14 @@ export function SettingsModal() {
<SettingsItem label="Queue tagging jobs" description="Generate missing AI tags for the current target. Results flow back into the library as the background worker finishes."> <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"> <div className="flex items-center gap-2">
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => runQueueAction("queue")} onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
> >
{taggerQueueing ? "Queueing..." : "Queue tagging"} {taggerQueueing ? "Queueing..." : "Queue tagging"}
</button> </button>
<button <button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")} onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
> >
@@ -600,11 +614,60 @@ export function SettingsModal() {
</div> </div>
) : ( ) : (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
<SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown
value={theme}
onChange={(value) => setTheme(value as AppTheme)}
ariaLabel="App theme"
options={[
{ value: "phokus", label: "Phokus" },
{ value: "subtle-light", label: "Subtle Light" },
{ value: "conventional-dark", label: "Conventional Dark" },
]}
/>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Video playback">
<SettingsItem
label="Autoplay in lightbox"
description="Start playing videos automatically when opened in the lightbox."
>
<button
role="switch"
aria-checked={lightboxAutoplay}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoplay ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoplay(!lightboxAutoplay)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoplay ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Start muted"
description="Open videos with their audio muted — unmute from the player controls."
>
<button
role="switch"
aria-checked={lightboxAutoMute}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${lightboxAutoMute ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setLightboxAutoMute(!lightboxAutoMute)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${lightboxAutoMute ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
<SettingsGroup title="Updates"> <SettingsGroup title="Updates">
<SettingsItem <SettingsItem
label={ label={
<span className="inline-flex items-center gap-2.5"> <span className="inline-flex items-center gap-2.5">
<span>Phokus {appVersion ? `v${appVersion}` : "—"}</span> <span>Phokus {appVersion ? `v${appVersion}` : "—"}</span>
{buildVariant ? (
<StatusPill tone={buildVariant === "cuda" ? "ready" : "muted"}>
{buildVariant === "cuda" ? "CUDA" : "CPU"}
</StatusPill>
) : null}
{updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? ( {updateStatus === "available" || updateStatus === "downloading" || updateStatus === "installing" ? (
<StatusPill tone="busy">v{updateVersion} available</StatusPill> <StatusPill tone="busy">v{updateVersion} available</StatusPill>
) : updateStatus === "upToDate" ? ( ) : updateStatus === "upToDate" ? (
@@ -616,7 +679,24 @@ export function SettingsModal() {
updateStatus === "error" ? ( updateStatus === "error" ? (
<span className="text-amber-300/90">Update check failed: {updateError}</span> <span className="text-amber-300/90">Update check failed: {updateError}</span>
) : updateStatus === "downloading" || updateStatus === "installing" ? ( ) : updateStatus === "downloading" || updateStatus === "installing" ? (
"Downloading update — the app will restart when it finishes." <span className="block">
<span className="text-gray-400">
{updateStatus === "installing"
? "Installing update…"
: updateProgress !== null
? `Downloading update — ${Math.round(updateProgress * 100)}%`
: "Downloading update…"}
</span>
<span className="mt-2 block h-1 overflow-hidden rounded-full bg-white/10">
<span
className={`block h-full rounded-full bg-emerald-400/80 transition-[width] duration-200 ${
updateProgress === null ? "w-full animate-pulse" : ""
}`}
style={updateProgress !== null ? { width: `${Math.round(updateProgress * 100)}%` } : undefined}
/>
</span>
<span className="mt-1 block text-gray-600">The app will restart when it finishes.</span>
</span>
) : ( ) : (
"Updates are checked quietly at launch and installed only when you choose." "Updates are checked quietly at launch and installed only when you choose."
) )
@@ -631,7 +711,7 @@ export function SettingsModal() {
</button> </button>
) : ( ) : (
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void checkForUpdates()} onClick={() => void checkForUpdates()}
disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"} disabled={updateStatus === "checking" || updateStatus === "downloading" || updateStatus === "installing"}
> >
@@ -639,6 +719,19 @@ export function SettingsModal() {
</button> </button>
)} )}
</SettingsItem> </SettingsItem>
{getChangelogForVersion(appVersion) ? (
<SettingsItem
label="What's new"
description={`See what changed in Phokus v${appVersion}.`}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={openWhatsNew}
>
View changes
</button>
</SettingsItem>
) : null}
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Setup"> <SettingsGroup title="Setup">
@@ -648,7 +741,7 @@ export function SettingsModal() {
description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features." description="Replay the guided first-run tour — library setup, the background pipeline, search modes, and AI features."
> >
<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" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { onClick={() => {
setSettingsOpen(false); setSettingsOpen(false);
openOnboarding(); openOnboarding();
@@ -665,7 +758,7 @@ export function SettingsModal() {
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models." description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
> >
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { onClick={() => {
setOpeningDataFolder(true); setOpeningDataFolder(true);
void openAppDataFolder().finally(() => setOpeningDataFolder(false)); void openAppDataFolder().finally(() => setOpeningDataFolder(false));
@@ -730,7 +823,7 @@ export function SettingsModal() {
} }
> >
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { onClick={() => {
setVacuuming(true); setVacuuming(true);
setVacuumResult(null); setVacuumResult(null);
@@ -748,6 +841,42 @@ export function SettingsModal() {
</button> </button>
</SettingsItem> </SettingsItem>
<SettingsItem
label="Rebuild semantic index"
description={
<>
<span>
Recreates the visual-embedding index and re-embeds every image in the
background. Use this if semantic or similar-image search reports a
dimension-mismatch error (for example after experimenting with a
different embedding model).
</span>
{rebuildIndexResult !== null ? (
<span className="mt-2 block text-gray-600">{rebuildIndexResult}</span>
) : null}
</>
}
>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => {
setRebuildingIndex(true);
setRebuildIndexResult(null);
void rebuildSemanticIndex()
.then((count) =>
setRebuildIndexResult(
`Re-queued ${count.toLocaleString()} image${count === 1 ? "" : "s"} for embedding.`,
),
)
.catch((error) => setRebuildIndexResult(String(error)))
.finally(() => setRebuildingIndex(false));
}}
disabled={rebuildingIndex}
>
{rebuildingIndex ? "Rebuilding…" : "Rebuild index"}
</button>
</SettingsItem>
<SettingsItem <SettingsItem
label="Thumbnail cache" label="Thumbnail cache"
description={ description={
@@ -791,7 +920,7 @@ export function SettingsModal() {
} }
> >
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => { onClick={() => {
setCleaningThumbnails(true); setCleaningThumbnails(true);
cleanupOrphanedThumbnails() cleanupOrphanedThumbnails()
+725 -14
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Reorder, useDragControls } from "framer-motion";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore, Folder, IndexProgress } from "../store"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useGalleryStore, Folder, Album, IndexProgress } from "../store";
import { ThemedDropdown } from "./ThemedDropdown";
interface ContextMenuState { interface ContextMenuState {
folderId: number; folderId: number;
@@ -8,6 +11,9 @@ interface ContextMenuState {
y: number; y: number;
} }
type LibrarySort = "az" | "za" | "custom";
const LIBRARY_SORT_KEY = "phokus-library-sort";
function FolderContextMenu({ function FolderContextMenu({
menu, menu,
folder, folder,
@@ -82,11 +88,22 @@ function FolderItem({
folder, folder,
selected, selected,
progress, progress,
customOrdering,
dragging,
onDragStart,
onDragEnd,
onKeyboardMove,
}: { }: {
folder: Folder; folder: Folder;
selected: boolean; selected: boolean;
progress: IndexProgress | undefined; progress: IndexProgress | undefined;
customOrdering: boolean;
dragging: boolean;
onDragStart: (pointerY: number) => void;
onDragEnd: () => void;
onKeyboardMove: (direction: -1 | 1) => void;
}) { }) {
const dragControls = useDragControls();
const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore(); const { selectFolder, removeFolder, reindexFolder, renameFolder, updateFolderPath, toggleMutedFolder } = useGalleryStore();
const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds); const mutedFolderIds = useGalleryStore((state) => state.mutedFolderIds);
const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused); const setAllWorkersPaused = useGalleryStore((state) => state.setAllWorkersPaused);
@@ -141,16 +158,57 @@ function FolderItem({
}; };
return ( return (
<> <Reorder.Item
as="div"
value={folder.id}
drag={customOrdering ? "y" : false}
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
className={`relative z-0 ${dragging ? "z-20" : ""}`}
style={{ position: "relative" }}
>
<div <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"
}`} } ${dragging ? "scale-[1.02] bg-white/[0.11] text-white shadow-xl shadow-black/25 ring-1 ring-white/15" : ""}`}
onClick={() => !renaming && selectFolder(folder.id)} onClick={() => !renaming && selectFolder(folder.id)}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
> >
{customOrdering ? (
<button
type="button"
aria-label={`Reorder ${folder.name}`}
title={`Drag to reorder ${folder.name}`}
className={`-ml-1 flex h-7 w-5 shrink-0 touch-none items-center justify-center rounded-md transition-colors ${
dragging
? "cursor-grabbing bg-white/10 text-gray-300"
: "cursor-grab text-gray-700 hover:bg-white/[0.06] hover:text-gray-400"
}`}
onPointerDown={(event) => {
event.stopPropagation();
onDragStart(event.clientY);
dragControls.start(event);
}}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
event.stopPropagation();
onKeyboardMove(event.key === "ArrowUp" ? -1 : 1);
}}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
) : null}
{isMissing ? ( {isMissing ? (
<span className="shrink-0 text-amber-400"> <span className="shrink-0 text-amber-400">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -278,28 +336,500 @@ function FolderItem({
onRemove={() => setConfirmingRemoval(true)} onRemove={() => setConfirmingRemoval(true)}
/> />
)} )}
</> </Reorder.Item>
); );
} }
function AlbumContextMenu({
x,
y,
onClose,
onRename,
onDelete,
}: {
x: number;
y: number;
onClose: () => void;
onRename: () => void;
onDelete: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleDown);
document.addEventListener("keydown", handleKey);
return () => {
document.removeEventListener("mousedown", handleDown);
document.removeEventListener("keydown", handleKey);
};
}, [onClose]);
const item = (label: string, onClick: () => void, danger = false) => (
<button
className={`w-full text-left px-3 py-1.5 text-[12px] rounded-md transition-colors ${
danger
? "text-red-400 hover:bg-red-500/15 hover:text-red-300"
: "text-gray-300 hover:bg-white/8 hover:text-white"
}`}
onClick={(event) => {
event.stopPropagation();
onClick();
onClose();
}}
>
{label}
</button>
);
return (
<div
ref={ref}
className="fixed z-50 min-w-[160px] py-1 px-1 rounded-lg bg-gray-900 border border-white/10 shadow-xl shadow-black/50"
style={{ left: x, top: y }}
>
{item("Rename", onRename)}
<div className="my-1 border-t border-white/[0.06]" />
{item("Delete album", onDelete, true)}
</div>
);
}
function AlbumItem({
album,
manageMode = false,
selectedForManage = false,
onToggleManage,
reorderable = false,
onDragStart,
onDragEnd,
}: {
album: Album;
manageMode?: boolean;
selectedForManage?: boolean;
onToggleManage?: () => void;
reorderable?: boolean;
onDragStart?: () => void;
onDragEnd?: () => void;
}) {
const dragControls = useDragControls();
const viewAlbum = useGalleryStore((state) => state.viewAlbum);
const renameAlbum = useGalleryStore((state) => state.renameAlbum);
const deleteAlbum = useGalleryStore((state) => state.deleteAlbum);
const activeView = useGalleryStore((state) => state.activeView);
const selectedAlbumId = useGalleryStore((state) => state.selectedAlbumId);
const selected = !manageMode && activeView === "album" && selectedAlbumId === album.id;
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(album.name);
const [confirmingRemoval, setConfirmingRemoval] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renaming) {
setRenameValue(album.name);
setTimeout(() => renameInputRef.current?.select(), 0);
}
}, [renaming, album.name]);
const commitRename = async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== album.name) {
await renameAlbum(album.id, trimmed);
}
setRenaming(false);
};
const cover = album.cover_thumbnail_path ? convertFileSrc(album.cover_thumbnail_path) : null;
const row = (
<div
role={manageMode ? "checkbox" : "button"}
tabIndex={renaming ? -1 : 0}
aria-checked={manageMode ? selectedForManage : undefined}
aria-current={!manageMode && selected ? "page" : undefined}
className={`group relative flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all duration-150 ${
selectedForManage
? "bg-blue-500/10 text-white ring-1 ring-inset ring-blue-400/50"
: selected
? "bg-white/8 text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5"
}`}
onClick={() => {
if (manageMode) {
onToggleManage?.();
} else if (!renaming) {
viewAlbum(album.id);
}
}}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (renaming || (e.key !== "Enter" && e.key !== " ")) return;
e.preventDefault();
if (manageMode) {
onToggleManage?.();
} else {
viewAlbum(album.id);
}
}}
onContextMenu={(e) => {
if (manageMode) return;
e.preventDefault();
e.stopPropagation();
setMenu({ x: Math.min(e.clientX, window.innerWidth - 180), y: Math.min(e.clientY, window.innerHeight - 120) });
}}
>
{/* Manage-mode selection checkbox */}
{manageMode ? (
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors ${
selectedForManage ? "border-blue-400 bg-blue-500 text-white" : "border-white/30 text-transparent"
}`}
>
<svg className="h-2.5 w-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
) : null}
{/* Drag handle — hover-revealed, reorders albums */}
{reorderable ? (
<button
type="button"
aria-label={`Reorder ${album.name}`}
title="Drag to reorder"
className="-ml-1 flex h-6 w-3.5 shrink-0 cursor-grab touch-none items-center justify-center rounded text-gray-700 opacity-0 transition-opacity hover:text-gray-400 group-hover:opacity-100"
onPointerDown={(e) => {
e.stopPropagation();
onDragStart?.();
dragControls.start(e);
}}
onClick={(e) => e.stopPropagation()}
>
<svg className="h-3 w-3" viewBox="0 0 12 12" fill="currentColor">
<circle cx="3" cy="3" r="1" /><circle cx="9" cy="3" r="1" />
<circle cx="3" cy="6" r="1" /><circle cx="9" cy="6" r="1" />
<circle cx="3" cy="9" r="1" /><circle cx="9" cy="9" r="1" />
</svg>
</button>
) : null}
{/* Cover thumbnail — distinguishes albums from folder rows */}
<div className="h-7 w-7 shrink-0 overflow-hidden rounded-md bg-white/[0.05] ring-1 ring-white/10">
{cover ? (
<img src={cover} alt="" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
<div className="min-w-0 flex-1">
{renaming ? (
<input
ref={renameInputRef}
className="w-full bg-white/10 text-white text-[13px] font-medium rounded px-1 py-0 outline-none ring-1 ring-blue-500/60 leading-tight"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setRenaming(false);
}}
onBlur={() => void commitRename()}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className={`truncate text-[13px] font-medium leading-tight ${selected ? "text-white" : ""}`}>
{album.name}
</div>
)}
<div className="text-[11px] text-gray-600 mt-0.5">{album.image_count.toLocaleString()}</div>
</div>
{!renaming && confirmingRemoval ? (
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300 transition-colors"
onClick={() => { void deleteAlbum(album.id); setConfirmingRemoval(false); }}
>
Confirm
</button>
<button
className="px-1.5 py-0.5 text-[10px] rounded bg-white/5 text-gray-500 hover:bg-white/10 hover:text-gray-300 transition-colors"
onClick={() => setConfirmingRemoval(false)}
>
Cancel
</button>
</div>
) : null}
{menu ? (
<AlbumContextMenu
x={menu.x}
y={menu.y}
onClose={() => setMenu(null)}
onRename={() => setRenaming(true)}
onDelete={() => setConfirmingRemoval(true)}
/>
) : null}
</div>
);
if (reorderable) {
return (
<Reorder.Item
as="div"
value={album.id}
drag="y"
dragControls={dragControls}
dragListener={false}
dragElastic={0.08}
onDragEnd={onDragEnd}
layout
transition={{ layout: { type: "spring", stiffness: 520, damping: 38, mass: 0.55 } }}
>
{row}
</Reorder.Item>
);
}
return row;
}
export function Sidebar() { export function Sidebar() {
const folders = useGalleryStore((state) => state.folders); const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const addFolder = useGalleryStore((state) => state.addFolder); const setFolderPickerOpen = useGalleryStore((state) => state.setFolderPickerOpen);
const indexingProgress = useGalleryStore((state) => state.indexingProgress); const indexingProgress = useGalleryStore((state) => state.indexingProgress);
const selectFolder = useGalleryStore((state) => state.selectFolder); const selectFolder = useGalleryStore((state) => state.selectFolder);
const activeView = useGalleryStore((state) => state.activeView); const activeView = useGalleryStore((state) => state.activeView);
const setView = useGalleryStore((state) => state.setView); const setView = useGalleryStore((state) => state.setView);
const reorderFolders = useGalleryStore((state) => state.reorderFolders);
const albums = useGalleryStore((state) => state.albums);
const createAlbum = useGalleryStore((state) => state.createAlbum);
const deleteAlbums = useGalleryStore((state) => state.deleteAlbums);
const reorderAlbums = useGalleryStore((state) => state.reorderAlbums);
const [creatingAlbum, setCreatingAlbum] = useState(false);
const [createAlbumPending, setCreateAlbumPending] = useState(false);
const [newAlbumName, setNewAlbumName] = useState("");
const newAlbumInputRef = useRef<HTMLInputElement>(null);
const [manageAlbums, setManageAlbums] = useState(false);
const [manageSelectedIds, setManageSelectedIds] = useState<Set<number>>(new Set());
const [confirmingAlbumDelete, setConfirmingAlbumDelete] = useState(false);
const [orderedAlbums, setOrderedAlbums] = useState(albums);
const orderedAlbumsRef = useRef(albums);
const [draggingAlbum, setDraggingAlbum] = useState(false);
const handleAddFolder = async () => { // Keep the local drag order in sync with the store except mid-drag, so a
const selected = await open({ directory: true, multiple: false, title: "Select Media Folder" }); // background album refresh doesn't yank the row out from under the pointer.
if (selected && typeof selected === "string") { useEffect(() => {
await addFolder(selected); if (draggingAlbum) return;
setOrderedAlbums(albums);
orderedAlbumsRef.current = albums;
}, [albums, draggingAlbum]);
const handleAlbumReorder = (ids: number[]) => {
const byId = new Map(orderedAlbumsRef.current.map((album) => [album.id, album]));
const next = ids
.map((id) => byId.get(id))
.filter((album): album is Album => album !== undefined);
orderedAlbumsRef.current = next;
setOrderedAlbums(next);
};
const finishAlbumReorder = () => {
setDraggingAlbum(false);
const nextIds = orderedAlbumsRef.current.map((album) => album.id);
// Read live store order (not the render-time closure) in case albums changed.
const currentIds = useGalleryStore.getState().albums.map((album) => album.id);
const snapshotIds = albums.map((album) => album.id);
if (
snapshotIds.length !== currentIds.length ||
snapshotIds.some((id, index) => id !== currentIds[index])
) {
orderedAlbumsRef.current = useGalleryStore.getState().albums;
setOrderedAlbums(orderedAlbumsRef.current);
return;
}
if (
nextIds.length !== currentIds.length ||
nextIds.some((id, index) => id !== currentIds[index])
) {
void reorderAlbums(nextIds);
}
};
const [librarySort, setLibrarySortState] = useState<LibrarySort>(() => {
const saved = window.localStorage.getItem(LIBRARY_SORT_KEY);
return saved === "za" || saved === "custom" ? saved : "az";
});
const [customFolders, setCustomFolders] = useState(folders);
const [draggedFolderId, setDraggedFolderId] = useState<number | null>(null);
const folderListRef = useRef<HTMLDivElement>(null);
const customFoldersRef = useRef(folders);
const pointerYRef = useRef(0);
const autoScrollFrameRef = useRef<number | null>(null);
const keyboardPersistRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current);
},
[],
);
useEffect(() => {
if (draggedFolderId !== null) return;
setCustomFolders(folders);
customFoldersRef.current = folders;
}, [folders, draggedFolderId]);
useEffect(() => {
if (draggedFolderId === null) return;
const handlePointerMove = (event: PointerEvent) => {
pointerYRef.current = event.clientY;
};
const autoScroll = () => {
const list = folderListRef.current;
if (list) {
const rect = list.getBoundingClientRect();
const edgeSize = Math.min(64, rect.height * 0.2);
const topDistance = pointerYRef.current - rect.top;
const bottomDistance = rect.bottom - pointerYRef.current;
let velocity = 0;
if (topDistance < edgeSize) {
velocity = -Math.pow((edgeSize - Math.max(0, topDistance)) / edgeSize, 1.6) * 14;
} else if (bottomDistance < edgeSize) {
velocity = Math.pow((edgeSize - Math.max(0, bottomDistance)) / edgeSize, 1.6) * 14;
}
if (velocity !== 0) list.scrollTop += velocity;
}
autoScrollFrameRef.current = requestAnimationFrame(autoScroll);
};
window.addEventListener("pointermove", handlePointerMove, { passive: true });
autoScrollFrameRef.current = requestAnimationFrame(autoScroll);
return () => {
window.removeEventListener("pointermove", handlePointerMove);
if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current);
autoScrollFrameRef.current = null;
};
}, [draggedFolderId]);
const displayedFolders = useMemo(() => {
if (librarySort === "custom") return customFolders;
return [...folders].sort((a, b) => {
const result = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
return librarySort === "az" ? result : -result;
});
}, [customFolders, folders, librarySort]);
const setLibrarySort = (sort: LibrarySort) => {
window.localStorage.setItem(LIBRARY_SORT_KEY, sort);
setLibrarySortState(sort);
};
const handleReorder = (orderedIds: number[]) => {
const byId = new Map(customFoldersRef.current.map((folder) => [folder.id, folder]));
const next = orderedIds
.map((id) => byId.get(id))
.filter((folder): folder is Folder => folder !== undefined);
customFoldersRef.current = next;
setCustomFolders(next);
};
const finishReorder = () => {
const nextIds = customFoldersRef.current.map((folder) => folder.id);
setDraggedFolderId(null);
const currentIds = folders.map((folder) => folder.id);
if (nextIds.some((id, index) => id !== currentIds[index])) {
void reorderFolders(nextIds);
} }
}; };
const moveFolderByKeyboard = (folderId: number, direction: -1 | 1) => {
const current = customFoldersRef.current;
const currentIndex = current.findIndex((folder) => folder.id === folderId);
const nextIndex = currentIndex + direction;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= current.length) return;
const next = [...current];
[next[currentIndex], next[nextIndex]] = [next[nextIndex], next[currentIndex]];
customFoldersRef.current = next;
setCustomFolders(next);
// Debounce the DB write so a held arrow key doesn't fire one per keystroke;
// the local order updates immediately, only the persist waits to settle.
if (keyboardPersistRef.current) clearTimeout(keyboardPersistRef.current);
keyboardPersistRef.current = setTimeout(() => {
keyboardPersistRef.current = null;
void reorderFolders(customFoldersRef.current.map((folder) => folder.id));
}, 400);
};
const handleAddFolder = () => {
setFolderPickerOpen(true);
};
const startCreatingAlbum = () => {
setCreatingAlbum(true);
setNewAlbumName("");
setTimeout(() => newAlbumInputRef.current?.focus(), 0);
};
const handleCreateAlbum = async () => {
const name = newAlbumName.trim();
if (!name) {
setCreatingAlbum(false);
return;
}
if (createAlbumPending) return;
setCreateAlbumPending(true);
try {
const album = await createAlbum(name);
setNewAlbumName("");
setCreatingAlbum(false);
useGalleryStore.getState().viewAlbum(album.id);
} finally {
setCreateAlbumPending(false);
}
};
const exitManageAlbums = () => {
setManageAlbums(false);
setManageSelectedIds(new Set());
setConfirmingAlbumDelete(false);
};
const toggleManageSelected = (albumId: number) => {
setManageSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(albumId)) next.delete(albumId);
else next.add(albumId);
return next;
});
setConfirmingAlbumDelete(false);
};
const handleDeleteSelectedAlbums = async () => {
const ids = Array.from(manageSelectedIds);
if (ids.length === 0) return;
await deleteAlbums(ids);
exitManageAlbums();
};
return ( return (
<aside className="w-60 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06]"> <aside className="w-52 shrink-0 flex flex-col bg-gray-950 border-r border-white/[0.06] lg:w-60">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0"> <div className="flex items-center justify-between px-4 h-12 border-b border-white/[0.06] shrink-0">
<span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span> <span className="text-[13px] font-semibold text-white/80 tracking-wide">Phokus</span>
@@ -387,27 +917,208 @@ export function Sidebar() {
{/* Section label */} {/* Section label */}
{folders.length > 0 && ( {folders.length > 0 && (
<div className="px-5 pt-3 pb-1"> <div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span> <span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">Libraries</span>
<ThemedDropdown
value={librarySort}
onChange={(value) => setLibrarySort(value as LibrarySort)}
ariaLabel="Library order"
compact
options={[
{ value: "az", label: "A-Z" },
{ value: "za", label: "Z-A" },
{ value: "custom", label: "Custom" },
]}
/>
</div> </div>
)} )}
{/* Folder list */} {/* Folder list */}
<div className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0"> <Reorder.Group
ref={folderListRef}
as="div"
axis="y"
values={displayedFolders.map((folder) => folder.id)}
onReorder={librarySort === "custom" ? handleReorder : () => {}}
layoutScroll
className="flex-1 overflow-y-auto px-2 pb-2 space-y-px min-h-0"
>
{folders.length === 0 ? ( {folders.length === 0 ? (
<p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed"> <p className="text-gray-700 text-xs px-3 py-6 text-center leading-relaxed">
Add a folder to get started Add a folder to get started
</p> </p>
) : ( ) : (
folders.map((folder) => ( displayedFolders.map((folder) => (
<FolderItem <FolderItem
key={folder.id} key={folder.id}
folder={folder} folder={folder}
selected={selectedFolderId === folder.id} selected={selectedFolderId === folder.id}
progress={indexingProgress[folder.id]} progress={indexingProgress[folder.id]}
customOrdering={librarySort === "custom"}
dragging={draggedFolderId === folder.id}
onDragStart={(pointerY) => {
pointerYRef.current = pointerY;
setDraggedFolderId(folder.id);
}}
onDragEnd={finishReorder}
onKeyboardMove={(direction) => moveFolderByKeyboard(folder.id, direction)}
/> />
)) ))
)} )}
</Reorder.Group>
{/* Albums a visually distinct block, separated from Libraries by a
heavier divider and given cover-thumbnail rows so the two never
read as one list. */}
<div className="shrink-0 border-t-2 border-white/[0.08] bg-white/[0.015]">
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-1">
<span className="text-[10px] font-semibold uppercase tracking-[0.15em] text-gray-600">
{manageAlbums ? `${manageSelectedIds.size} selected` : "Albums"}
</span>
{manageAlbums ? (
<button
onClick={exitManageAlbums}
className="rounded px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500 transition-colors hover:text-gray-200"
>
Done
</button>
) : (
<div className="flex items-center gap-0.5">
{albums.length > 0 ? (
<button
onClick={() => setManageAlbums(true)}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="Manage albums"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
) : null}
<button
onClick={startCreatingAlbum}
className="rounded p-0.5 text-gray-600 transition-colors hover:bg-white/8 hover:text-gray-200"
title="New album"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
)}
</div>
{/* Manage action row */}
{manageAlbums ? (
<div className="px-3 pb-1.5">
{confirmingAlbumDelete ? (
<div className="rounded-lg border border-red-500/25 bg-red-500/[0.06] p-2">
<p className="mb-2 text-[11px] leading-relaxed text-gray-400">
Delete {manageSelectedIds.size} album{manageSelectedIds.size === 1 ? "" : "s"}? Your images stay in
the library only the album{manageSelectedIds.size === 1 ? "" : "s"} {manageSelectedIds.size === 1 ? "is" : "are"} removed.
</p>
<div className="flex justify-end gap-1.5">
<button
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => setConfirmingAlbumDelete(false)}
>
Cancel
</button>
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] font-medium text-red-300 transition-colors hover:bg-red-500/30 hover:text-red-200"
onClick={() => void handleDeleteSelectedAlbums()}
>
Delete {manageSelectedIds.size}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<button
className="text-[11px] text-gray-500 transition-colors hover:text-gray-300"
onClick={() =>
setManageSelectedIds((prev) =>
prev.size === albums.length ? new Set() : new Set(albums.map((a) => a.id)),
)
}
>
{manageSelectedIds.size === albums.length ? "Deselect all" : "Select all"}
</button>
<button
className="rounded-md border border-red-400/25 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => setConfirmingAlbumDelete(true)}
disabled={manageSelectedIds.size === 0}
>
Delete {manageSelectedIds.size > 0 ? manageSelectedIds.size : ""}
</button>
</div>
)}
</div>
) : null}
<div className="max-h-52 overflow-y-auto px-2 pb-2 space-y-px">
{creatingAlbum ? (
<form
className="flex gap-1 px-1 py-1"
onSubmit={(e) => {
e.preventDefault();
void handleCreateAlbum();
}}
>
<input
ref={newAlbumInputRef}
className="min-w-0 flex-1 rounded border border-white/10 bg-white/10 px-1.5 py-1 text-[13px] text-white outline-none ring-1 ring-blue-500/40 placeholder-gray-600"
placeholder="Album name…"
value={newAlbumName}
onChange={(e) => setNewAlbumName(e.target.value)}
disabled={createAlbumPending}
onKeyDown={(e) => {
if (createAlbumPending) return;
if (e.key === "Escape") {
setCreatingAlbum(false);
setNewAlbumName("");
}
}}
onBlur={() => void handleCreateAlbum()}
/>
</form>
) : null}
{albums.length === 0 && !creatingAlbum ? (
<p className="px-3 py-3 text-center text-[11px] leading-relaxed text-gray-700">
Select images and Add to album to start curating
</p>
) : manageAlbums ? (
albums.map((album) => (
<AlbumItem
key={album.id}
album={album}
manageMode
selectedForManage={manageSelectedIds.has(album.id)}
onToggleManage={() => toggleManageSelected(album.id)}
/>
))
) : (
<Reorder.Group
as="div"
axis="y"
values={orderedAlbums.map((album) => album.id)}
onReorder={handleAlbumReorder}
className="space-y-px"
>
{orderedAlbums.map((album) => (
<AlbumItem
key={album.id}
album={album}
reorderable
onDragStart={() => setDraggingAlbum(true)}
onDragEnd={finishAlbumReorder}
/>
))}
</Reorder.Group>
)}
</div>
</div> </div>
</aside> </aside>
); );
+336 -86
View File
@@ -1,8 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion } from "framer-motion"; import { motion, useReducedMotion } from "framer-motion";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { Tooltip } from "./Tooltip";
const ACCENTS = [ const ACCENTS = [
"#60a5fa", "#60a5fa",
@@ -17,6 +18,21 @@ const ACCENTS = [
"#f87171", "#f87171",
]; ];
// Darker variants of each accent for the light theme — the bright originals are
// tuned for dark cards and wash out on the cream background.
const LIGHT_ACCENTS = [
"#2563eb",
"#9333ea",
"#16a34a",
"#d97706",
"#db2777",
"#0d9488",
"#ea580c",
"#7c3aed",
"#059669",
"#dc2626",
];
const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5)); const GOLDEN_ANGLE = Math.PI * (3 - Math.sqrt(5));
function seeded(n: number): number { function seeded(n: number): number {
@@ -31,6 +47,7 @@ interface PlacedNode {
y: number; y: number;
w: number; w: number;
h: number; h: number;
zIndex: number;
accent: string; accent: string;
driftX: number; driftX: number;
driftY: number; driftY: number;
@@ -44,17 +61,34 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
const maxCount = Math.max(...entries.map((e) => e.count)); const maxCount = Math.max(...entries.map((e) => e.count));
const cx = containerW / 2; const cx = containerW / 2;
const cy = containerH / 2; const cy = containerH / 2;
// Spread ellipse shrinks slightly to leave room for card half-widths at the edges
const spreadX = containerW * 0.42;
const spreadY = containerH * 0.36;
const n = entries.length; const n = entries.length;
const ASPECT = 0.72;
const PAD = 18;
// 1. Build initial positions using phyllotaxis spiral // Card width scales with image count; the sub-linear exponent (< 1) widens the
// gap so the busiest clusters read as clearly larger and more prominent.
const rawWidth = (count: number) => {
const ratio = Math.max(count / maxCount, 0.05);
return 92 + Math.pow(ratio, 0.65) * 158; // ~92250px before fit scaling
};
// Shrink every card uniformly when their padded footprint can't fit the
// canvas, so overlap resolution can actually pull them apart instead of
// settling into a pile. (0.6 leaves headroom for imperfect packing.)
const totalArea = entries.reduce((sum, e) => {
const w = rawWidth(e.count);
return sum + (w + PAD) * (w * ASPECT + PAD);
}, 0);
const usableArea = containerW * containerH * 0.6;
const fit = totalArea > usableArea ? Math.sqrt(usableArea / totalArea) : 1;
const spreadX = containerW * 0.44;
const spreadY = containerH * 0.4;
// 1. Seed positions on a phyllotaxis spiral, sized by count.
const nodes: PlacedNode[] = entries.map((entry, i) => { const nodes: PlacedNode[] = entries.map((entry, i) => {
const ratio = Math.max(entry.count / maxCount, 0.08); const w = rawWidth(entry.count) * fit;
// Cards scale from 110px to 230px wide; height is 3/4 of width const h = w * ASPECT;
const w = 110 + Math.sqrt(ratio) * 120;
const h = w * 0.75;
const radialRatio = Math.sqrt((i + 0.5) / n); const radialRatio = Math.sqrt((i + 0.5) / n);
const angle = i * GOLDEN_ANGLE; const angle = i * GOLDEN_ANGLE;
@@ -65,6 +99,9 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
y: cy + Math.sin(angle) * radialRatio * spreadY, y: cy + Math.sin(angle) * radialRatio * spreadY,
w, w,
h, h,
// Bigger (busier) clusters stack above smaller ones, so they stay
// clickable even if a sliver of overlap survives.
zIndex: Math.round(w),
accent: ACCENTS[i % ACCENTS.length], accent: ACCENTS[i % ACCENTS.length],
driftX: (seeded(i + 11) - 0.5) * 18, driftX: (seeded(i + 11) - 0.5) * 18,
driftY: (seeded(i + 17) - 0.5) * 14, driftY: (seeded(i + 17) - 0.5) * 14,
@@ -73,9 +110,12 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
}; };
}); });
// 2. Iterative overlap resolution — no physics, just push apart // 2. Resolve overlaps by pushing pairs apart, clamping inside the canvas every
const PAD = 24; // pass so edge cards settle in-bounds instead of being shoved out and
for (let iter = 0; iter < 80; iter++) { // re-overlapping there.
const marginX = 14;
const marginY = 14;
for (let iter = 0; iter < 160; iter++) {
for (let a = 0; a < nodes.length; a++) { for (let a = 0; a < nodes.length; a++) {
const na = nodes[a]; const na = nodes[a];
for (let b = a + 1; b < nodes.length; b++) { for (let b = a + 1; b < nodes.length; b++) {
@@ -85,57 +125,69 @@ function buildCloud(entries: TagCloudEntry[], containerW: number, containerH: nu
const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx); const overlapX = (na.w + nb.w) / 2 + PAD - Math.abs(dx);
const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy); const overlapY = (na.h + nb.h) / 2 + PAD - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue; if (overlapX <= 0 || overlapY <= 0) continue;
// Push along the smaller overlap axis // Push along the smaller overlap axis (ternary yields ±1 so coincident
// cards still separate rather than stalling at a zero push).
if (overlapX < overlapY) { if (overlapX < overlapY) {
const push = overlapX * 0.5 * (dx >= 0 ? 1 : -1); const push = (overlapX / 2) * (dx >= 0 ? 1 : -1);
nb.x += push; nb.x += push;
na.x -= push; na.x -= push;
} else { } else {
const push = overlapY * 0.5 * (dy >= 0 ? 1 : -1); const push = (overlapY / 2) * (dy >= 0 ? 1 : -1);
nb.y += push; nb.y += push;
na.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; for (const node of nodes) {
na.y += (cy + Math.sin(na.index * GOLDEN_ANGLE) * Math.sqrt((na.index + 0.5) / n) * spreadY - na.y) * 0.05; node.x = Math.min(Math.max(node.x, node.w / 2 + marginX), containerW - node.w / 2 - marginX);
node.y = Math.min(Math.max(node.y, node.h / 2 + marginY), containerH - node.h / 2 - marginY);
} }
} }
// 3. Clamp so cards never poke outside the container return nodes;
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 }) { function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imageIds: number[]) => void; animated: boolean }) {
const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null; const src = node.entry.thumbnail_path ? convertFileSrc(node.entry.thumbnail_path) : null;
const { w, h, accent } = node; const { w, h, accent } = node;
const driftTransition = {
duration: node.driftDuration,
ease: "easeInOut" as const,
delay: seeded(node.index + 41) * 1.6,
repeat: 1,
repeatType: "reverse" as const,
};
return ( return (
<motion.button <motion.button
className="group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_40px_rgba(0,0,0,0.5)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" className="explore-cluster-card group absolute overflow-hidden rounded-2xl border border-white/8 bg-white/[0.04] text-left shadow-[0_8px_28px_rgba(0,0,0,0.38)] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2 }} style={{ width: w, height: h, left: node.x - w / 2, top: node.y - h / 2, zIndex: node.zIndex }}
initial={{ opacity: 0, scale: 0.75 }} initial={animated ? { opacity: 0, scale: 0.82, rotate: node.rotateSeed } : { opacity: 0, scale: 0.96 }}
animate={{ animate={
opacity: 1, animated
scale: 1, ? {
x: [0, node.driftX, 0], opacity: 1,
y: [0, node.driftY, 0], scale: 1,
rotate: [node.rotateSeed, node.rotateSeed + 1.4, node.rotateSeed], x: [0, node.driftX * 0.65, 0],
}} y: [0, node.driftY * 0.65, 0],
transition={{ rotate: [node.rotateSeed, node.rotateSeed + 0.8, node.rotateSeed],
opacity: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) }, }
scale: { duration: 0.3, delay: Math.min(node.index * 0.035, 0.7) }, : { opacity: 1, scale: 1, rotate: node.rotateSeed }
x: { duration: node.driftDuration, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 41) * 3 }, }
y: { duration: node.driftDuration + 1.6, repeat: Infinity, ease: "easeInOut", delay: seeded(node.index + 51) * 3 }, transition={
rotate: { duration: node.driftDuration + 0.9, repeat: Infinity, ease: "easeInOut" }, animated
}} ? {
whileHover={{ scale: 1.06, rotate: 0, transition: { duration: 0.18 } }} opacity: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
scale: { duration: 0.24, delay: Math.min(node.index * 0.024, 0.45) },
x: driftTransition,
y: { ...driftTransition, duration: node.driftDuration + 1.2, delay: seeded(node.index + 51) * 1.6 },
rotate: { ...driftTransition, duration: node.driftDuration + 0.8, delay: seeded(node.index + 61) * 1.2 },
}
: { opacity: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) }, scale: { duration: 0.18, delay: Math.min(node.index * 0.016, 0.28) } }
}
whileHover={{ scale: 1.06, rotate: 0, zIndex: 500, transition: { duration: 0.18 } }}
onClick={() => onOpen(node.entry.image_ids)} onClick={() => onOpen(node.entry.image_ids)}
title={`Open cluster — ${node.entry.count.toLocaleString()} images`} title={`Open cluster — ${node.entry.count.toLocaleString()} ${node.entry.count === 1 ? "image" : "images"}`}
> >
{src ? ( {src ? (
<img <img
@@ -143,25 +195,27 @@ function CloudCard({ node, onOpen }: { node: PlacedNode; onOpen: (imageIds: numb
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105" className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
draggable={false} draggable={false}
loading="lazy"
decoding="async"
/> />
) : ( ) : (
<div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" /> <div className="absolute inset-0 bg-gradient-to-br from-white/[0.07] to-transparent" />
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" /> <div className="explore-cluster-overlay absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" />
{/* Accent glow on hover */} {/* Accent glow on hover */}
<div <div
className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100" className="absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }} style={{ background: `radial-gradient(ellipse at bottom, ${accent}25, transparent 70%)` }}
/> />
<div className="absolute inset-x-0 bottom-0 p-3"> <div className="absolute inset-x-0 bottom-0 p-3">
<div className="mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} /> <div className="explore-cluster-rule mb-2 h-px rounded-full" style={{ background: `linear-gradient(to right, ${accent}80, transparent)` }} />
<div className="flex items-end justify-between gap-2"> <div className="flex items-end justify-between gap-2">
<div> <div>
<p className="text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p> <p className="explore-cluster-label text-[9px] uppercase tracking-[0.18em] text-white/35">Cluster</p>
<p className="text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p> <p className="explore-cluster-count text-base font-semibold leading-none text-white">{node.entry.count.toLocaleString()}</p>
</div> </div>
<span <span
className="rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100" className="explore-cluster-open rounded-full border px-2 py-0.5 text-[9px] uppercase tracking-[0.1em] opacity-0 transition-opacity duration-200 group-hover:opacity-100"
style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }} style={{ borderColor: `${accent}50`, color: accent, backgroundColor: `${accent}12` }}
> >
Open Open
@@ -186,42 +240,52 @@ function TagWord({
logRange: number; logRange: number;
onSearch: (tag: string) => void; onSearch: (tag: string) => void;
}) { }) {
const theme = useGalleryStore((state) => state.theme);
const isLight = theme === "subtle-light";
const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5;
const fontSize = 11 + ratio * 28; // 11px 39px const fontSize = 11 + ratio * 28; // 11px 39px
const accent = ACCENTS[index % ACCENTS.length]; const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length];
const tilt = (seeded(index + 5) - 0.5) * 7; const tilt = (seeded(index + 5) - 0.5) * 7;
// Faint low-frequency words read fine as subtle white-on-dark, but the same low
// opacity is unreadable on the light theme's cream, so raise the floor there.
const minOpacity = isLight ? 0.6 : 0.4;
return ( return (
<motion.button <Tooltip
initial={{ opacity: 0, scale: 0.6 }} label={`${entry.tag}${entry.count.toLocaleString()} ${entry.count === 1 ? "image" : "images"}`}
animate={{ opacity: 0.4 + ratio * 0.6, scale: 1 }} followCursor
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }} delay={250}
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 <motion.button
className="font-medium leading-none" initial={{ opacity: 0, scale: 0.6 }}
style={{ color: ratio > 0.55 ? accent : "rgba(255,255,255,0.82)" }} animate={{ opacity: minOpacity + ratio * (1 - minOpacity), scale: 1 }}
transition={{ delay: Math.min(index * 0.008, 0.55), duration: 0.22 }}
whileHover={{ scale: 1.2, opacity: 1, rotate: 0, transition: { duration: 0.14 } }}
className="explore-tag-word group inline-flex items-center gap-1 rounded-full px-2 py-1 transition-colors hover:bg-white/[0.07]"
style={{ fontSize, rotate: tilt }}
onClick={() => onSearch(entry.tag)}
> >
{entry.tag} <span
</span> className="font-medium leading-none"
<span style={{ color: ratio > 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }}
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.tag}
> </span>
{entry.count.toLocaleString()} <span
</span> className="rounded-full px-1.5 py-0.5 text-[9px] tabular-nums opacity-0 transition-opacity group-hover:opacity-100"
</motion.button> style={{ backgroundColor: `${accent}22`, color: accent }}
>
{entry.count.toLocaleString()}
</span>
</motion.button>
</Tooltip>
); );
} }
function Spinner() { function Spinner() {
return ( return (
<motion.div <motion.div
className="h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50" className="explore-spinner h-5 w-5 rounded-full border-2 border-white/15 border-t-white/50"
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }} transition={{ duration: 0.85, repeat: Infinity, ease: "linear" }}
/> />
@@ -238,6 +302,7 @@ function ClusterCloud({
entries: TagCloudEntry[]; entries: TagCloudEntry[];
onOpen: (imageIds: number[]) => void; onOpen: (imageIds: number[]) => void;
}) { }) {
const reducedMotion = useReducedMotion();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 });
@@ -260,15 +325,176 @@ function ClusterCloud({
); );
return ( return (
<div ref={canvasRef} className="relative min-h-0 flex-1 overflow-hidden"> <div ref={canvasRef} className="relative isolate 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]" /> <div className="explore-cluster-grid pointer-events-none absolute inset-0 opacity-[0.07] [background-image:radial-gradient(circle,rgba(255,255,255,0.6)_1px,transparent_1px)] [background-size:28px_28px]" />
{nodes.map((node) => ( {nodes.map((node) => (
<CloudCard key={`${node.entry.representative_image_id}:${node.index}`} node={node} onOpen={onOpen} /> <CloudCard
key={`${node.entry.representative_image_id}:${node.index}`}
node={node}
onOpen={onOpen}
animated={!reducedMotion && node.index < 12}
/>
))} ))}
</div> </div>
); );
} }
// A flat, manageable row for a single tag — rename (which doubles as merge when
// the new name already exists) and delete across the whole library.
function TagManageRow({
entry,
onSearch,
onRename,
onDelete,
}: {
entry: ExploreTagEntry;
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(entry.tag);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing) {
setValue(entry.tag);
setTimeout(() => inputRef.current?.select(), 0);
}
}, [editing, entry.tag]);
const commitRename = async () => {
const next = value.trim();
if (!next || next === entry.tag) {
setEditing(false);
return;
}
setBusy(true);
try {
await onRename(entry.tag, next);
setEditing(false);
} finally {
setBusy(false);
}
};
return (
<div className="group flex items-center gap-3 rounded-lg px-3 py-2 transition-colors hover:bg-white/[0.04]">
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
className="w-full rounded border border-white/10 bg-white/10 px-2 py-1 text-sm text-white outline-none ring-1 ring-blue-500/40"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void commitRename(); }
if (e.key === "Escape") setEditing(false);
}}
disabled={busy}
/>
) : (
<button
className="truncate text-left text-sm text-white/85 transition-colors hover:text-white"
onClick={() => onSearch(entry.tag)}
title="Search this tag"
>
{entry.tag}
</button>
)}
</div>
<span className="shrink-0 text-xs tabular-nums text-white/30">{entry.count.toLocaleString()}</span>
{editing ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-blue-500/20 px-2 py-1 text-[11px] text-blue-200 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
onClick={() => void commitRename()}
disabled={busy || !value.trim()}
title="Rename (merges into the target if it already exists)"
>
Save
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setEditing(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : confirming ? (
<div className="flex shrink-0 items-center gap-1">
<button
className="rounded-md bg-red-500/20 px-2 py-1 text-[11px] text-red-300 transition-colors hover:bg-red-500/30 disabled:opacity-50"
onClick={async () => { setBusy(true); try { await onDelete(entry.tag); setConfirming(false); } finally { setBusy(false); } }}
disabled={busy}
>
Delete
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/50 transition-colors hover:bg-white/5 hover:text-white/80"
onClick={() => setConfirming(false)}
disabled={busy}
>
Cancel
</button>
</div>
) : (
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)}
title="Rename or merge into another tag"
>
Rename
</button>
<button
className="rounded-md border border-white/10 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)}
>
Delete
</button>
</div>
)}
</div>
);
}
function TagManageList({
entries,
onSearch,
onRename,
onDelete,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
}) {
return (
<div className="mx-auto w-full max-w-2xl overflow-y-auto px-6 py-6">
<p className="mb-3 px-3 text-[11px] leading-relaxed text-white/30">
Rename a tag to clean it up, or rename it to an existing tag's name to merge them. Delete
removes a tag from every image. These changes apply across your whole library.
</p>
<div className="divide-y divide-white/[0.05]">
{entries.map((entry) => (
<TagManageRow
key={entry.tag}
entry={entry}
onSearch={onSearch}
onRename={onRename}
onDelete={onDelete}
/>
))}
</div>
</div>
);
}
export function TagCloud() { export function TagCloud() {
const exploreMode = useGalleryStore((state) => state.exploreMode); const exploreMode = useGalleryStore((state) => state.exploreMode);
const setExploreMode = useGalleryStore((state) => state.setExploreMode); const setExploreMode = useGalleryStore((state) => state.setExploreMode);
@@ -280,7 +506,11 @@ export function TagCloud() {
const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags);
const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster);
const searchForTag = useGalleryStore((state) => state.searchForTag); const searchForTag = useGalleryStore((state) => state.searchForTag);
const renameTag = useGalleryStore((state) => state.renameTag);
const deleteTag = useGalleryStore((state) => state.deleteTag);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const [manageTags, setManageTags] = useState(false);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
useEffect(() => { useEffect(() => {
if (exploreMode === "visual") void loadTagCloud(); if (exploreMode === "visual") void loadTagCloud();
@@ -300,13 +530,14 @@ export function TagCloud() {
const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length;
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]"> <div className="explore-view flex min-h-0 flex-1 flex-col overflow-hidden bg-[radial-gradient(ellipse_at_top,rgba(59,130,246,0.08),transparent_50%),radial-gradient(ellipse_at_80%_75%,rgba(168,85,247,0.07),transparent_40%),#07080f]">
{/* Header */} {/* Header `relative z-10` keeps the folder-scope dropdown above the
<div className="shrink-0 border-b border-white/[0.05] px-6 py-4"> cluster canvas, whose cards use a high z-index of their own. */}
<div className="explore-header relative z-10 shrink-0 border-b border-white/[0.05] px-6 py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-[15px] font-semibold text-white">Explore</h2> <h2 className="explore-title text-[15px] font-semibold text-white">Explore</h2>
<p className="mt-0.5 truncate text-[11px] text-white/30"> <p className="explore-subtitle mt-0.5 truncate text-[11px] text-white/30">
{loading {loading
? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…" ? exploreMode === "visual" ? "Computing visual clusters…" : "Loading tags…"
: hasEntries : hasEntries
@@ -319,10 +550,22 @@ export function TagCloud() {
</p> </p>
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<FolderScopeDropdown /> {exploreMode === "tags" && hasEntries ? (
<div className="flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button <button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ className={`rounded-lg border px-3 py-1.5 text-xs transition-colors ${
manageTags
? "border-white/15 bg-white/10 text-white"
: "border-white/8 bg-white/[0.03] text-gray-500 hover:text-gray-300"
}`}
onClick={() => setManageTags((v) => !v)}
>
{manageTags ? "Done" : "Manage"}
</button>
) : null}
<FolderScopeDropdown />
<div className="explore-mode-toggle flex rounded-lg border border-white/8 bg-white/[0.03] p-0.5">
<button
className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" exploreMode === "visual" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`} }`}
onClick={() => setExploreMode("visual")} onClick={() => setExploreMode("visual")}
@@ -330,7 +573,7 @@ export function TagCloud() {
Clusters Clusters
</button> </button>
<button <button
className={`rounded-md px-3 py-1.5 text-xs transition-colors ${ className={`explore-mode-button rounded-md px-3 py-1.5 text-xs transition-colors ${
exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300" exploreMode === "tags" ? "bg-white/10 text-white" : "text-gray-500 hover:text-gray-300"
}`} }`}
onClick={() => setExploreMode("tags")} onClick={() => setExploreMode("tags")}
@@ -343,13 +586,13 @@ export function TagCloud() {
</div> </div>
{loading ? ( {loading ? (
<div className="flex flex-1 items-center justify-center gap-3 text-white/25"> <div className="explore-empty flex flex-1 items-center justify-center gap-3 text-white/25">
<Spinner /> <Spinner />
<span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span> <span className="text-sm">{exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"}</span>
</div> </div>
) : !hasEntries ? ( ) : !hasEntries ? (
<div className="flex flex-1 items-center justify-center px-8"> <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"> <p className="explore-empty max-w-xs text-center text-sm leading-relaxed text-white/25">
{exploreMode === "visual" {exploreMode === "visual"
? "No visual clusters yet. Images need embeddings before they can be grouped. Check indexing progress in the sidebar." ? "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."} : "No tags yet. Run the AI tagger from Settings, or add tags manually in the image preview."}
@@ -357,6 +600,13 @@ export function TagCloud() {
</div> </div>
) : exploreMode === "visual" ? ( ) : exploreMode === "visual" ? (
<ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} /> <ClusterCloud entries={tagCloudEntries} onOpen={showVisualCluster} />
) : manageTags ? (
<TagManageList
entries={exploreTagEntries}
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
/>
) : ( ) : (
/* Tag cloud — words sized by log-scaled frequency, wrapped freely */ /* Tag cloud — words sized by log-scaled frequency, wrapped freely */
<div className="overflow-y-auto px-8 py-8"> <div className="overflow-y-auto px-8 py-8">
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
export interface DropdownOption {
value: string;
label: string;
}
export function ThemedDropdown({
value,
options,
onChange,
ariaLabel,
compact = false,
align = "right",
}: {
value: string;
options: DropdownOption[];
onChange: (value: string) => void;
ariaLabel: string;
compact?: boolean;
align?: "left" | "right";
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const current = options.find((option) => option.value === value) ?? options[0];
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (!ref.current?.contains(event.target as Node)) setOpen(false);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((currentOpen) => !currentOpen)}
className={`flex items-center justify-between gap-2 rounded-md border transition-colors ${
compact
? "border-white/10 bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium light-theme:border-gray-700/50 light-theme:bg-gray-900"
: "min-w-40 border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs light-theme:border-gray-700/50 light-theme:bg-gray-900"
} ${open ? "border-white/20 text-white light-theme:border-gray-700 light-theme:text-white" : "text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:text-white light-theme:hover:border-gray-700 light-theme:hover:bg-gray-800 light-theme:hover:text-white"}`}
>
<span>{current?.label}</span>
<svg
className={`h-3 w-3 shrink-0 text-gray-500 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open ? (
<div
role="listbox"
aria-label={ariaLabel}
className={`absolute top-full z-50 mt-1.5 min-w-full rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl shadow-black/30 backdrop-blur-xl light-theme:border-gray-700/50 ${
align === "right" ? "right-0" : "left-0"
}`}
>
{options.map((option) => {
const selected = option.value === value;
return (
<button
key={option.value}
type="button"
role="option"
aria-selected={selected}
className={`flex w-full items-center justify-between gap-4 whitespace-nowrap rounded-lg px-3 py-2 text-left text-xs transition-colors ${
selected
? "bg-white/[0.08] text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/[0.055] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
onClick={() => {
onChange(option.value);
setOpen(false);
}}
>
<span>{option.label}</span>
{selected ? (
<svg className="h-3.5 w-3.5 text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
) : null}
</button>
);
})}
</div>
) : null}
</div>
);
}
+267 -110
View File
@@ -5,6 +5,8 @@ import { ContextMenu, ImageTile } from "./Gallery";
const GAP = 6; const GAP = 6;
const HEADER_HEIGHT = 52; const HEADER_HEIGHT = 52;
const SCRUBBER_WIDTH = 48;
const MONTH_SHORT = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
interface TimelineGroup { interface TimelineGroup {
key: string; key: string;
@@ -12,6 +14,23 @@ interface TimelineGroup {
images: ImageRecord[]; images: ImageRecord[];
} }
// One virtualized row: either a month header or a row of up to `cols` tiles.
type TimelineRow =
| { type: "header"; group: TimelineGroup }
| { type: "tiles"; images: ImageRecord[] };
interface ScrubberMonth {
monthNum: number;
label: string;
groupIndex: number;
}
interface ScrubberYear {
year: string;
firstGroupIndex: number;
months: ScrubberMonth[];
}
function buildLabel(key: string): string { function buildLabel(key: string): string {
if (key === "unknown") return "Unknown Date"; if (key === "unknown") return "Unknown Date";
const [yearStr, monthStr] = key.split("-"); const [yearStr, monthStr] = key.split("-");
@@ -44,6 +63,27 @@ function groupImages(images: ImageRecord[]): TimelineGroup[] {
.map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs })); .map(([key, imgs]) => ({ key, label: buildLabel(key), images: imgs }));
} }
function buildScrubberYears(groups: TimelineGroup[]): ScrubberYear[] {
const byYear = new Map<string, ScrubberYear>();
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
if (group.key === "unknown") continue;
const year = group.key.substring(0, 4);
const monthNum = Number(group.key.substring(5, 7));
if (!byYear.has(year)) {
byYear.set(year, { year, firstGroupIndex: i, months: [] });
}
byYear.get(year)!.months.push({
monthNum,
label: MONTH_SHORT[monthNum - 1] ?? "",
groupIndex: i,
});
}
// Keep insertion order so the scrubber runs the same direction as the scrolled
// content (oldest at top with taken_asc), keeping the active highlight aligned.
return Array.from(byYear.values());
}
export function Timeline() { export function Timeline() {
const images = useGalleryStore((s) => s.images); const images = useGalleryStore((s) => s.images);
const loadMoreImages = useGalleryStore((s) => s.loadMoreImages); const loadMoreImages = useGalleryStore((s) => s.loadMoreImages);
@@ -55,13 +95,15 @@ export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
x: number; x: number;
y: number; y: number;
image: ImageRecord; image: ImageRecord;
} | null>(null); } | null>(null);
// Measure container width before first paint to avoid a single-column flash. // parentRef is the scroll container. Its clientWidth already excludes the
// scrubber because they are flex siblings, so no further adjustment is needed.
useLayoutEffect(() => { useLayoutEffect(() => {
const el = parentRef.current; const el = parentRef.current;
if (!el) return; if (!el) return;
@@ -74,35 +116,59 @@ export function Timeline() {
}, []); }, []);
const tileSize = tileSizeForZoom(zoomPreset); const tileSize = tileSizeForZoom(zoomPreset);
const groups = useMemo(() => groupImages(images), [images]);
const scrubberYears = useMemo(() => buildScrubberYears(groups), [groups]);
// Show as soon as there's more than one month to jump between — not gated on
// a full year. With taken_asc sort the loaded set is oldest-first, so this
// reflects whatever range is currently loaded.
const showScrubber = groups.length > 1;
const cols = useMemo( const cols = useMemo(
() => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))), () => Math.max(1, Math.floor((containerWidth - GAP) / (tileSize + GAP))),
[containerWidth, tileSize], [containerWidth, tileSize],
); );
const groups = useMemo(() => groupImages(images), [images]); // Flatten the month groups into a single list of fixed-height rows — one
// header row per group, then one tile-row per `cols` images. This lets the
// virtualizer render only the on-screen rows, exactly like the Gallery.
// Previously each *group* was one virtual item that rendered ALL of its
// images, so scrolling into a busy month mounted thousands of tiles at once.
const { rows, rowToGroupIndex, groupFirstRow } = useMemo(() => {
const rows: TimelineRow[] = [];
const rowToGroupIndex: number[] = [];
const groupFirstRow: number[] = [];
groups.forEach((group, groupIndex) => {
groupFirstRow[groupIndex] = rows.length;
rows.push({ type: "header", group });
rowToGroupIndex.push(groupIndex);
for (let i = 0; i < group.images.length; i += cols) {
rows.push({ type: "tiles", images: group.images.slice(i, i + cols) });
rowToGroupIndex.push(groupIndex);
}
});
return { rows, rowToGroupIndex, groupFirstRow };
}, [groups, cols]);
// estimateSize must be exact so virtualizer positions groups correctly.
// Each group height = header + rowCount * (tileSize + GAP) where the last row's
// GAP acts as spacing between this group and the next header.
const estimateSize = useCallback( const estimateSize = useCallback(
(index: number): number => { (index: number): number =>
const group = groups[index]; rows[index]?.type === "header" ? HEADER_HEIGHT : tileSize + GAP,
if (!group) return HEADER_HEIGHT; [rows, tileSize],
const rowCount = Math.ceil(group.images.length / cols);
return HEADER_HEIGHT + rowCount * (tileSize + GAP);
},
[groups, cols, tileSize],
); );
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: groups.length, count: rows.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize, estimateSize,
overscan: 2, overscan: 6,
paddingStart: GAP,
}); });
// Re-measure all items when cols changes so virtualizer positions stay accurate // Refs so the scroll handler can read the latest mappings without re-binding.
// after a window resize (react-virtual v3 doesn't invalidate cached sizes on its own). const rowToGroupIndexRef = useRef(rowToGroupIndex);
rowToGroupIndexRef.current = rowToGroupIndex;
const groupFirstRowRef = useRef(groupFirstRow);
groupFirstRowRef.current = groupFirstRow;
useEffect(() => { useEffect(() => {
virtualizer.measure(); virtualizer.measure();
}, [cols, virtualizer]); }, [cols, virtualizer]);
@@ -110,12 +176,25 @@ export function Timeline() {
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
const el = parentRef.current; const el = parentRef.current;
if (!el) return; if (!el) return;
if (el.scrollTop < 24) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 600; // Active month = the group owning the first row still visible at the top.
const scrollTop = el.scrollTop;
const items = virtualizer.getVirtualItems();
let activeRow = items.length > 0 ? items[0].index : 0;
for (const item of items) {
if (item.start + item.size > scrollTop + HEADER_HEIGHT / 2) {
activeRow = item.index;
break;
}
}
setActiveGroupIndex(rowToGroupIndexRef.current[activeRow] ?? 0);
if (scrollTop < 24) return;
const nearBottom = scrollTop + el.clientHeight >= el.scrollHeight - 600;
if (nearBottom && !loadingImages && images.length < totalImages) { if (nearBottom && !loadingImages && images.length < totalImages) {
void loadMoreImages(); void loadMoreImages();
} }
}, [images.length, loadMoreImages, loadingImages, totalImages]); }, [virtualizer, images.length, loadMoreImages, loadingImages, totalImages]);
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = parentRef.current;
@@ -140,105 +219,135 @@ export function Timeline() {
}; };
}, []); }, []);
return ( const scrollToGroup = useCallback(
<div (groupIndex: number) => {
ref={parentRef} const row = groupFirstRowRef.current[groupIndex] ?? 0;
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0 bg-[#07080f]" virtualizer.scrollToIndex(row, { align: "start" });
> },
{images.length === 0 && loadingImages ? ( [virtualizer],
<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, return (
matching the row-to-row gap and making estimateSize exact. */} // Outer flex-row: fills remaining height in <main>'s flex-col, then
// splits horizontally between the scroll area and the scrubber.
<div className="flex flex-1 min-h-0 bg-gray-950">
{/* Scroll container — flex-1 takes all width except the scrubber */}
<div
ref={parentRef}
className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0"
>
{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 row = rows[virtualItem.index];
if (!row) return null;
return (
<div <div
key={virtualItem.key}
style={{ style={{
display: "grid", position: "absolute",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`, top: virtualItem.start,
gap: GAP, width: "100%",
paddingLeft: GAP, height: virtualItem.size,
paddingRight: GAP,
paddingBottom: GAP,
}} }}
> >
{group.images.map((image) => ( {row.type === "header" ? (
<ImageTile <div
key={image.id} className="flex items-center gap-3 px-4"
image={image} style={{ height: HEADER_HEIGHT }}
onClick={() => openImage(image)} >
onContextMenu={(event) => { <span className="text-sm font-semibold text-white/80 shrink-0">
event.preventDefault(); {row.group.label}
setContextMenu({ x: event.clientX, y: event.clientY, image }); </span>
<span className="text-xs text-white/25 shrink-0 tabular-nums">
{row.group.images.length}
</span>
<div className="flex-1 h-px bg-white/[0.06]" />
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, ${tileSize}px)`,
gap: GAP,
paddingLeft: GAP,
paddingRight: GAP,
paddingBottom: GAP,
boxSizing: "border-box",
}} }}
/> >
))} {row.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>
</div> );
); })}
})} </div>
</div> )}
)}
{images.length > 0 && 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>
) : null}
</div>
{/* Scrubber — flex sibling so it stays visible while the left panel scrolls */}
{showScrubber ? (
<div
className="overflow-y-auto border-l border-white/[0.04] py-2 flex flex-col items-center gap-0.5"
style={{ width: SCRUBBER_WIDTH }}
>
{scrubberYears.map((yearEntry) => (
<ScrubberYearBlock
key={yearEntry.year}
yearEntry={yearEntry}
activeGroupIndex={activeGroupIndex}
onScrollTo={scrollToGroup}
/>
))}
</div> </div>
) : null} ) : null}
@@ -253,3 +362,51 @@ export function Timeline() {
</div> </div>
); );
} }
interface ScrubberYearBlockProps {
yearEntry: ScrubberYear;
activeGroupIndex: number;
onScrollTo: (index: number) => void;
}
function ScrubberYearBlock({ yearEntry, activeGroupIndex, onScrollTo }: ScrubberYearBlockProps) {
const isYearActive = yearEntry.months.some((m) => m.groupIndex === activeGroupIndex);
return (
<div className="w-full flex flex-col items-center">
<button
className={`w-full text-center py-0.5 text-[10px] font-semibold tracking-wide transition-colors ${
isYearActive ? "text-white/80" : "text-white/30 hover:text-white/55"
}`}
onClick={() => onScrollTo(yearEntry.firstGroupIndex)}
title={yearEntry.year}
>
{yearEntry.year}
</button>
<div
className="grid gap-[3px] pb-1.5"
style={{ gridTemplateColumns: "repeat(3, 10px)" }}
>
{Array.from({ length: 12 }, (_, i) => {
const monthNum = i + 1;
const monthEntry = yearEntry.months.find((m) => m.monthNum === monthNum);
const isActive = monthEntry !== undefined && monthEntry.groupIndex === activeGroupIndex;
if (!monthEntry) {
return <span key={monthNum} className="h-[10px] w-[10px]" />;
}
return (
<button
key={monthNum}
title={`${monthEntry.label} ${yearEntry.year}`}
onClick={() => onScrollTo(monthEntry.groupIndex)}
className={`h-[10px] w-[10px] rounded-full transition-colors ${
isActive ? "bg-white/70" : "bg-white/15 hover:bg-white/40"
}`}
/>
);
})}
</div>
</div>
);
}
+14 -17
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { useGalleryStore } from "../store"; import { useGalleryStore } from "../store";
import { PhokusMark } from "./PhokusMark"; import { PhokusMark } from "./PhokusMark";
import { Tooltip } from "./Tooltip";
// SVG icons for window controls // SVG icons for window controls
function MinimizeIcon() { function MinimizeIcon() {
@@ -24,7 +25,7 @@ function RestoreIcon() {
return ( return (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none"> <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" /> <rect x="2.5" y="0.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="#030712" /> <rect x="0.5" y="2.5" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1" fill="var(--color-gray-950)" />
</svg> </svg>
); );
} }
@@ -79,22 +80,18 @@ export function TitleBar() {
up its focal point and the chip becomes a button that re-opens the prompt. */} 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">
{updatePending ? ( {updatePending ? (
<div <div style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}>
className="group relative" {/* Instant tooltip (delay 0) — this affordance should read immediately. */}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} <Tooltip label={`Click to update — v${updateVersion}`} delay={0} align="start">
> <button
<button onClick={() => void installUpdate()}
onClick={() => void installUpdate()} aria-label={`Update available — click to update to Phokus v${updateVersion}`}
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"
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-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-amber-400/60 animate-ping" />
<span className="pointer-events-none absolute left-1/2 top-1/2 h-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" />
<PhokusMark className="relative h-4 w-4" dotClassName="fill-amber-400" /> </button>
</button> </Tooltip>
{/* 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"> <div className="flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300">
+51 -23
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store";
import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { FolderScopeDropdown } from "./FolderScopeDropdown";
import { ColorFilter } from "./ColorFilter";
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" },
@@ -55,8 +56,8 @@ function SortDropdown({
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${ className={`flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors ${
open open
? "border-white/15 bg-white/8 text-white" ? "border-white/15 bg-white/8 text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white"
: "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200" : "border-white/8 bg-transparent text-gray-400 hover:border-white/15 hover:text-gray-200 light-theme:border-gray-700/40 light-theme:text-gray-600 light-theme:hover:border-gray-700 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
> >
<span>{current?.label ?? "Sort"}</span> <span>{current?.label ?? "Sort"}</span>
@@ -68,14 +69,14 @@ function SortDropdown({
</svg> </svg>
</button> </button>
{open ? ( {open ? (
<div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur"> <div className="absolute right-0 top-full z-30 mt-1.5 min-w-44 rounded-xl border border-white/10 bg-gray-950/98 p-1 shadow-2xl backdrop-blur light-theme:border-gray-700/50">
{options.map((option) => ( {options.map((option) => (
<button <button
key={option.value} key={option.value}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
option.value === value option.value === value
? "bg-white/6 text-white" ? "bg-white/6 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-400 hover:bg-white/5 hover:text-white" : "text-gray-400 hover:bg-white/5 hover:text-white light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={() => { onChange(option.value); setOpen(false); }} onClick={() => { onChange(option.value); setOpen(false); }}
> >
@@ -106,14 +107,14 @@ function FilterPill({
}) { }) {
const activeClass = const activeClass =
variant === "amber" variant === "amber"
? "bg-amber-500/15 text-amber-300 border border-amber-500/30" ? "bg-amber-500/15 text-amber-300 border border-amber-500/30 light-theme:bg-amber-100 light-theme:text-amber-700 light-theme:border-amber-500/50"
: "bg-white/10 text-white"; : "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white";
return ( return (
<button <button
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${ className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
active active
? activeClass ? activeClass
: "text-gray-500 hover:text-gray-200 hover:bg-white/5" : "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
onClick={onClick} onClick={onClick}
> >
@@ -158,14 +159,20 @@ export function Toolbar() {
const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); 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 failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly);
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const colorFilter = useGalleryStore((state) => state.colorFilter);
const setColorFilter = useGalleryStore((state) => state.setColorFilter);
const similarScope = useGalleryStore((state) => state.similarScope); const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
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 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 hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0);
const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null); const [searchCommand, setSearchCommand] = useState<SearchCommand | null>(null);
const [searchQuery, setSearchQuery] = useState(search); const [searchQuery, setSearchQuery] = useState(search);
@@ -184,6 +191,12 @@ export function Toolbar() {
const hasActiveSearch = search.trim().length > 0; const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery)); const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images"; const isSimilarResults = collectionTitle === "Similar Images";
// Album scope is offered while browsing an album (where it's the default) and
// while viewing similar/region results that were launched from an album.
const showAlbumScope =
activeView === "album" ||
(similarSourceAlbumId !== null &&
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
// If current sort is video-only but we switched away from video filter, reset to date_desc // If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => { useEffect(() => {
@@ -255,10 +268,10 @@ export function Toolbar() {
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">
{/* Primary row */} {/* Primary row */}
<div className="flex items-center gap-3 px-5 h-12"> <div className="flex items-center gap-2 px-3 h-12 lg:gap-3 lg:px-5">
{/* Title + count */} {/* Title + count */}
<div className="flex items-baseline gap-2.5 min-w-0 shrink-0"> <div className="flex items-baseline gap-2.5 min-w-0 shrink-0">
<h2 className="text-[15px] font-semibold text-white truncate max-w-48">{title}</h2> <h2 className="text-[15px] font-semibold text-white truncate max-w-32 lg:max-w-48">{title}</h2>
<span className="text-xs text-gray-600 shrink-0"> <span className="text-xs text-gray-600 shrink-0">
{loadedCount < totalImages {loadedCount < totalImages
? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}` ? `${loadedCount.toLocaleString()} / ${totalImages.toLocaleString()}`
@@ -309,7 +322,7 @@ export function Toolbar() {
}} }}
onFocus={() => setSearchPanelOpen(true)} onFocus={() => setSearchPanelOpen(true)}
placeholder="Search files, or use /s /t" placeholder="Search files, or use /s /t"
className={`w-64 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors ${searchCommand !== null ? "pl-16" : "pl-8"}`} className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/> />
{searchCommand !== null ? ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2"> <div className="absolute left-8 top-1/2 -translate-y-1/2">
@@ -419,7 +432,7 @@ export function Toolbar() {
<div className="h-4 w-px bg-white/10 shrink-0" /> <div className="h-4 w-px bg-white/10 shrink-0" />
{/* Zoom */} {/* Zoom */}
<div className="flex items-center rounded-lg border border-white/8 overflow-hidden"> <div className="flex items-center rounded-lg border border-white/8 overflow-hidden light-theme:border-gray-700/40">
{(["compact", "comfortable", "detail"] as const).map((preset, i) => ( {(["compact", "comfortable", "detail"] as const).map((preset, i) => (
<button <button
key={preset} key={preset}
@@ -427,8 +440,8 @@ export function Toolbar() {
i > 0 ? "border-l border-white/8" : "" i > 0 ? "border-l border-white/8" : ""
} ${ } ${
zoomPreset === preset zoomPreset === preset
? "bg-white/10 text-white" ? "bg-white/10 text-white light-theme:bg-gray-900 light-theme:text-white"
: "text-gray-500 hover:text-gray-200 hover:bg-white/5" : "text-gray-500 hover:text-gray-200 hover:bg-white/5 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`} }`}
title={`${tileSize}px tiles`} title={`${tileSize}px tiles`}
onClick={() => setZoomPreset(preset)} onClick={() => setZoomPreset(preset)}
@@ -439,14 +452,19 @@ export function Toolbar() {
</div> </div>
</div> </div>
{/* Filter row */} {/* Filter row pills scroll horizontally on narrow widths so they never
squash or stack; the color filter stays pinned to the right. */}
<div className="flex items-center gap-1 px-4 pb-1.5"> <div className="flex items-center gap-1 px-4 pb-1.5">
<FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && minimumRating === 0} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} /> <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="All" active={mediaFilter === "all" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly && minimumRating === 0 && colorFilter === null} onClick={() => { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); setColorFilter(null); }} />
<FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Images" active={mediaFilter === "image" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Videos" active={mediaFilter === "video" && !favoritesOnly && !failedEmbeddingsOnly && !failedTaggingOnly} onClick={() => { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} /> <FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
{showAlbumScope ? (
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
) : null}
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} /> <FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} /> <FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? ( {hasAnyFailedEmbeddings ? (
@@ -457,7 +475,17 @@ 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} {hasAnyFailedTagging ? (
<FilterPill
label="Failed Tags"
active={failedTaggingOnly}
variant="amber"
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 shrink-0 whitespace-nowrap text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
<ColorFilter />
</div> </div>
</div> </div>
); );
+145
View File
@@ -0,0 +1,145 @@
import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
type TooltipSide = "top" | "bottom" | "left" | "right";
type TooltipAlign = "center" | "start" | "end";
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === "left") return "right-full top-1/2 mr-1.5 -translate-y-1/2";
if (side === "right") return "left-full top-1/2 ml-1.5 -translate-y-1/2";
const vertical = side === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
const horizontal = align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return `${vertical} ${horizontal}`;
}
const BASE_CLASSES =
"pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg";
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`;
/**
* Lightweight custom tooltip fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer
* (fixed-positioned, so it escapes scroll-container clipping).
*/
export function Tooltip({
label,
delay = 400,
side = "bottom",
align = "center",
block = false,
followCursor = false,
className = "",
children,
}: {
label: ReactNode;
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number;
side?: TooltipSide;
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign;
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean;
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean;
/** Extra classes for the wrapper (e.g. layout). */
className?: string;
children: ReactNode;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ x: 0, y: 0 });
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const frame = useRef<number | undefined>(undefined);
const tooltipRef = useRef<HTMLSpanElement>(null);
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current;
const tipWidth = tip?.offsetWidth ?? 0;
const tipHeight = tip?.offsetHeight ?? 24;
const gap = 16;
let top = clientY + gap;
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight;
}
let left = clientX + 14;
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth;
if (left < 4) left = 4;
setCoords({ x: left, y: top });
};
const clear = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = undefined;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = undefined;
};
const show = (event: MouseEvent<HTMLElement>) => {
clear();
if (followCursor) place(event.clientX, event.clientY);
if (delay <= 0) {
setVisible(true);
return;
}
timer.current = setTimeout(() => setVisible(true), delay);
};
const hide = () => {
clear();
setVisible(false);
};
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return;
const { clientX, clientY } = event;
if (frame.current !== undefined) cancelAnimationFrame(frame.current);
frame.current = requestAnimationFrame(() => {
frame.current = undefined;
place(clientX, clientY);
});
};
useEffect(() => clear, []);
const Wrapper = block ? "div" : "span";
return (
<Wrapper
className={`${block ? "relative block w-full" : "relative inline-flex"} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{followCursor ? (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Fixed (so the scroll container's overflow doesn't clip it) and
// positioned by `place()` near the cursor with viewport-edge flipping.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: { type: "spring", stiffness: 700, damping: 45, mass: 0.35 },
top: { type: "spring", stiffness: 520, damping: 34, mass: 0.45 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? "opacity-100" : "opacity-0"}`}
>
{label}
</span>
)}
</Wrapper>
);
}
+14 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2]; const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
const CONTROLS_HIDE_DELAY_MS = 2500; const CONTROLS_HIDE_DELAY_MS = 2500;
@@ -57,7 +58,9 @@ export function VideoPlayer({ src }: { src: string }) {
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState<BufferedRange[]>([]); const [buffered, setBuffered] = useState<BufferedRange[]>([]);
const [volume, setVolume] = useState(persistedVolume); const [volume, setVolume] = useState(persistedVolume);
const [muted, setMuted] = useState(persistedMuted); const [muted, setMuted] = useState(
() => useGalleryStore.getState().lightboxAutoMute || persistedMuted,
);
const [playbackRate, setPlaybackRate] = useState(1); const [playbackRate, setPlaybackRate] = useState(1);
const [loop, setLoop] = useState(false); const [loop, setLoop] = useState(false);
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
@@ -89,11 +92,16 @@ export function VideoPlayer({ src }: { src: string }) {
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
// Read playback prefs fresh for each opened video — not reactive, so a
// settings change applies to the next video rather than the current one.
const { lightboxAutoplay, lightboxAutoMute } = useGalleryStore.getState();
const startMuted = lightboxAutoMute || persistedMuted;
video.volume = persistedVolume; video.volume = persistedVolume;
video.muted = persistedMuted; video.muted = startMuted;
// Autoplay; if the webview blocks it the catch leaves us paused with setMuted(startMuted);
// controls visible, which is a fine fallback. // Autoplay when enabled; if the webview blocks it the catch leaves us paused
video.play().catch(() => {}); // with controls visible, which is a fine fallback.
if (lightboxAutoplay) video.play().catch(() => {});
}, [src]); }, [src]);
const readBuffered = useCallback(() => { const readBuffered = useCallback(() => {
@@ -282,7 +290,7 @@ export function VideoPlayer({ src }: { src: string }) {
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={`relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`} className={`media-dark-surface relative flex h-full w-full items-center justify-center bg-black ${controlsVisible ? "" : "cursor-none"}`}
onPointerMove={showControls} onPointerMove={showControls}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { invoke } from "@tauri-apps/api/core";
import { useGalleryStore } from "../store";
import { getChangelogForVersion, type ChangelogSection } from "../changelog";
import { Tooltip } from "./Tooltip";
// Shown in the tooltip so the user can see the link's destination before
// clicking. The actual navigation is handled by the open_changelog_url command.
const CHANGELOG_URL = "https://github.com/JezzWTF/phokus/blob/main/CHANGELOG.md";
// Per-section accent. These all use the standard colour scale, which the
// subtle-light theme re-maps to readable dark shades via CSS variables — so no
// `light-theme:` overrides are needed (or wanted) here. See the theme note in
// index.css: `bg-white`/`text-white` deliberately become *dark* in light mode,
// so neutral surfaces must use the gray scale and trust the remap.
const SECTION_STYLES: Record<string, { label: string; dot: string }> = {
Added: { label: "text-emerald-300", dot: "bg-emerald-400/80" },
Changed: { label: "text-sky-300", dot: "bg-sky-300/80" },
Fixed: { label: "text-amber-300", dot: "bg-amber-400/80" },
Removed: { label: "text-rose-300", dot: "bg-rose-400/80" },
Security: { label: "text-violet-300", dot: "bg-violet-400/80" },
};
const NEUTRAL_STYLE = { label: "text-gray-300", dot: "bg-gray-400/70" };
// Render the small amount of inline markdown the changelog uses: **bold** and
// `code`. Anything else passes through as plain text.
function renderInline(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
const regex = /\*\*(.+?)\*\*|`([^`]+)`/g;
let lastIndex = 0;
let key = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(text.slice(lastIndex, match.index));
}
if (match[1] !== undefined) {
nodes.push(
<strong key={key++} className="font-semibold text-white">
{match[1]}
</strong>,
);
} else if (match[2] !== undefined) {
nodes.push(
<code key={key++} className="rounded bg-white/10 px-1 py-0.5 text-[12px] text-gray-200">
{match[2]}
</code>,
);
}
lastIndex = regex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(text.slice(lastIndex));
}
return nodes;
}
function Section({ section }: { section: ChangelogSection }) {
const style = SECTION_STYLES[section.title] ?? NEUTRAL_STYLE;
const [open, setOpen] = useState(true);
return (
<section>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 text-left"
aria-expanded={open}
>
<svg
className={`h-3 w-3 shrink-0 text-gray-600 transition-transform ${open ? "rotate-90" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 5l7 7-7 7" />
</svg>
<span className={`text-[11px] font-semibold uppercase tracking-[0.1em] ${style.label}`}>{section.title}</span>
<span className="text-[11px] font-medium text-gray-600">{section.items.length}</span>
</button>
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
<ul className="mt-2.5 space-y-2.5 pl-5">
{section.items.map((item, index) => (
<li key={index} className="flex gap-3">
<span className={`mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full ${style.dot}`} />
<p className="text-[13px] leading-relaxed text-gray-400">
{item.lead ? (
<>
<span className="font-semibold text-gray-100">{item.lead}</span>
{item.body ? <span> {renderInline(item.body)}</span> : null}
</>
) : (
renderInline(item.body)
)}
</p>
</li>
))}
</ul>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
export function WhatsNewModal() {
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const closeWhatsNew = useGalleryStore((s) => s.closeWhatsNew);
const appVersion = useGalleryStore((s) => s.appVersion);
const entry = getChangelogForVersion(appVersion);
useEffect(() => {
if (!whatsNewOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeWhatsNew();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [whatsNewOpen, closeWhatsNew]);
return (
<AnimatePresence>
{whatsNewOpen ? (
<motion.div
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm"
onClick={closeWhatsNew}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<motion.div
className="relative flex max-h-[min(82vh,760px)] w-[min(88vw,560px)] flex-col overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60"
onClick={(event) => event.stopPropagation()}
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 6 }}
transition={{ duration: 0.16 }}
>
<div className="flex items-start justify-between gap-4 border-b border-white/[0.07] px-6 py-5">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-300">What's new</p>
<h3 className="mt-1 text-lg font-semibold text-white">
Phokus v{entry?.version ?? appVersion ?? "—"}
</h3>
{entry?.date ? <p className="mt-0.5 text-xs text-gray-600">Released {entry.date}</p> : null}
</div>
<button
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
onClick={closeWhatsNew}
title="Close"
>
<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>
</div>
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-6 py-5">
{entry && entry.sections.length > 0 ? (
entry.sections.map((section) => <Section key={section.title} section={section} />)
) : (
<p className="text-sm text-gray-500">
Release notes for this version aren't available in-app. See the full changelog on GitHub.
</p>
)}
</div>
<div className="flex items-center justify-between gap-4 border-t border-white/[0.07] px-6 py-4">
<Tooltip label={CHANGELOG_URL} side="top" align="start">
<button
type="button"
onClick={() => void invoke("open_changelog_url")}
className="text-xs text-gray-500 transition-colors hover:text-gray-300"
>
Full changelog
</button>
</Tooltip>
<button
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-3.5 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
onClick={closeWhatsNew}
>
Got it
</button>
</div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { AnimatePresence, motion } from "framer-motion";
import { useGalleryStore } from "../store";
// Shown once on the first launch after an update, inviting the user to read the
// changelog. Distinct from UpdateToast (which drives the download/install flow).
export function WhatsNewToast() {
const whatsNewToast = useGalleryStore((s) => s.whatsNewToast);
const whatsNewOpen = useGalleryStore((s) => s.whatsNewOpen);
const openWhatsNew = useGalleryStore((s) => s.openWhatsNew);
const dismissWhatsNewToast = useGalleryStore((s) => s.dismissWhatsNewToast);
const visible = whatsNewToast !== null && !whatsNewOpen;
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-emerald-400/20 bg-gray-900 p-4 shadow-xl"
>
<p className="text-sm font-medium text-white">What's new in Phokus v{whatsNewToast}</p>
<p className="mt-1 text-xs text-gray-500">See what's changed in this version.</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={openWhatsNew}
>
What's new
</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={dismissWhatsNewToast}
>
Dismiss
</button>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useBulkTagEditor } from "./useBulkTagEditor";
// Presentational tag-editing fields shared by the popover and modal surfaces.
export function BulkTagFields({ autoFocus = false }: { autoFocus?: boolean }) {
const { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag } =
useBulkTagEditor();
return (
<div className="space-y-2">
<form
className="flex gap-1.5"
onSubmit={(event) => {
event.preventDefault();
void addTag(input);
}}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input
autoFocus={autoFocus}
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 to ${selectedCount} item${selectedCount === 1 ? "" : "s"}`}
value={input}
onChange={(event) => setInput(event.target.value)}
disabled={pending}
/>
<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={pending || !input.trim()}
>
Add
</button>
</form>
{suggestions.length > 0 ? (
<div className="flex flex-wrap gap-1">
{suggestions.map((suggestion) => (
<button
key={suggestion.tag}
className="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[11px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white"
onClick={() => void addTag(suggestion.tag)}
title={`${suggestion.count.toLocaleString()} tagged`}
>
{suggestion.tag}
</button>
))}
</div>
) : null}
{appliedTags.length > 0 ? (
<div className="flex flex-wrap gap-1 border-t border-white/[0.06] pt-2">
{appliedTags.map((tag) => (
<span
key={tag}
className="group/chip flex items-center gap-1 rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
>
{tag}
<button
className="text-gray-600 transition-colors hover:text-white"
title="Remove from selected"
onClick={() => void removeTag(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>
) : null}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { BulkTagFields } from "./BulkTagFields";
// Inline popover surface for bulk tagging — the default editing surface.
// Anchored above the bar by the parent; closes on outside click via the
// data-bulk-popover guard handled in BulkActionBar.
export function BulkTagPopover({ onClose }: { onClose: () => void }) {
return (
<div
data-bulk-popover
className="absolute bottom-full left-1/2 mb-2 w-72 -translate-x-1/2 rounded-xl border border-white/10 bg-gray-950/98 p-3 shadow-2xl backdrop-blur"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] uppercase tracking-wider text-gray-500">Add tags</p>
<button
className="text-gray-600 transition-colors hover:text-white"
onClick={onClose}
title="Close"
>
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<BulkTagFields autoFocus />
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { ExploreTagEntry, useGalleryStore } from "../../store";
// Shared logic for the bulk tag editor, consumed by both the inline popover and
// the modal surface so they stay behaviorally identical.
export function useBulkTagEditor() {
const selectedCount = useGalleryStore((state) => state.gallerySelectedIds.size);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
const bulkAddTags = useGalleryStore((state) => state.bulkAddTags);
const bulkRemoveTag = useGalleryStore((state) => state.bulkRemoveTag);
const [input, setInput] = useState("");
const [suggestions, setSuggestions] = useState<ExploreTagEntry[]>([]);
const [appliedTags, setAppliedTags] = useState<string[]>([]);
const [pending, setPending] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const requestRef = useRef(0);
useEffect(() => {
const query = input.trim();
if (!query) {
requestRef.current += 1;
setSuggestions([]);
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
const requestId = ++requestRef.current;
debounceRef.current = setTimeout(async () => {
try {
const results = await invoke<ExploreTagEntry[]>("search_tags_autocomplete", {
params: { query, folder_id: selectedFolderId ?? null, limit: 8 },
});
if (requestId !== requestRef.current) return;
setSuggestions(results);
} catch {
if (requestId !== requestRef.current) return;
setSuggestions([]);
}
}, 120);
return () => {
requestRef.current += 1;
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [input, selectedFolderId]);
const addTag = useCallback(
async (raw: string) => {
const tag = raw.trim();
if (!tag || pending) return;
setPending(true);
try {
await bulkAddTags([tag]);
setAppliedTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
setInput("");
setSuggestions([]);
} finally {
setPending(false);
}
},
[bulkAddTags, pending],
);
const removeTag = useCallback(
async (tag: string) => {
await bulkRemoveTag(tag);
setAppliedTags((prev) => prev.filter((entry) => entry !== tag));
},
[bulkRemoveTag],
);
return { selectedCount, input, setInput, suggestions, appliedTags, pending, addTag, removeTag };
}
@@ -43,10 +43,10 @@ export function OnboardingOverlay() {
const isLast = onboardingStep >= STEPS.length - 1; const isLast = onboardingStep >= STEPS.length - 1;
return ( return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm"> <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm light-theme:bg-black/40">
<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"> <div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-gray-950 shadow-2xl shadow-black/60 light-theme:border-gray-300/70">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4"> <div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<div> <div>
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600"> <p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
Step {onboardingStep + 1} of {STEPS.length} Step {onboardingStep + 1} of {STEPS.length}
@@ -59,7 +59,11 @@ export function OnboardingOverlay() {
key={s.id} key={s.id}
aria-label={s.title} aria-label={s.title}
className={`h-1.5 rounded-full transition-all ${ 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" i === onboardingStep
? "w-5 bg-white/70 light-theme:bg-gray-700"
: i < onboardingStep
? "w-1.5 bg-white/35 light-theme:bg-gray-400"
: "w-1.5 bg-white/15 light-theme:bg-gray-300"
}`} }`}
onClick={() => setOnboardingStep(i)} onClick={() => setOnboardingStep(i)}
/> />
@@ -83,16 +87,16 @@ export function OnboardingOverlay() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4"> <div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4 light-theme:border-gray-300/70">
<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" 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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={completeOnboarding} onClick={completeOnboarding}
> >
Skip tour Skip tour
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => setOnboardingStep(onboardingStep - 1)} onClick={() => setOnboardingStep(onboardingStep - 1)}
disabled={isFirst} disabled={isFirst}
> >
+7 -23
View File
@@ -1,26 +1,12 @@
import { useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useGalleryStore } from "../../store"; import { useGalleryStore } from "../../store";
import { FakeTile } from "./fakes"; import { FakeTile } from "./fakes";
export function StepAddLibrary() { export function StepAddLibrary() {
const folders = useGalleryStore((s) => s.folders); const folders = useGalleryStore((s) => s.folders);
const addFolder = useGalleryStore((s) => s.addFolder); const setFolderPickerOpen = useGalleryStore((s) => s.setFolderPickerOpen);
const [adding, setAdding] = useState(false);
const [addError, setAddError] = useState<string | null>(null);
const handlePick = async () => { const handlePick = () => {
setAddError(null); setFolderPickerOpen(true);
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 ( return (
@@ -30,7 +16,7 @@ export function StepAddLibrary() {
added, renamed, or removed. Nothing is moved or copied. added, renamed, or removed. Nothing is moved or copied.
</p> </p>
<div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4"> <div className="mt-5 flex items-center justify-between gap-6 border-y border-white/[0.05] py-4 light-theme:border-gray-300/70">
<div className="min-w-0"> <div className="min-w-0">
{folders.length > 0 ? ( {folders.length > 0 ? (
<> <>
@@ -49,14 +35,12 @@ export function StepAddLibrary() {
</p> </p>
</> </>
)} )}
{addError ? <p className="mt-1.5 text-xs text-amber-300/90">{addError}</p> : null}
</div> </div>
<button <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" 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()} onClick={handlePick}
disabled={adding}
> >
{adding ? "Adding..." : folders.length > 0 ? "Add another folder" : "Choose a folder"} {folders.length > 0 ? "Add another folder" : "Choose a folder"}
</button> </button>
</div> </div>
@@ -64,7 +48,7 @@ export function StepAddLibrary() {
As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen As indexing runs, the gallery fills in roughly like this tiles appear immediately and sharpen
as thumbnails are generated: as thumbnails are generated:
</p> </p>
<div className="mt-3 grid grid-cols-6 gap-1.5"> <div className="media-dark-surface mt-3 grid grid-cols-6 gap-1.5">
<FakeTile index={0} /> <FakeTile index={0} />
<FakeTile index={1} favorite /> <FakeTile index={1} favorite />
<FakeTile index={2} duration="0:42" /> <FakeTile index={2} duration="0:42" />
+8 -8
View File
@@ -37,18 +37,18 @@ export function StepAiFeatures() {
</p> </p>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">AI tagging optional</h4> <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="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<div className="py-4"> <div className="py-4">
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p> <p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <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{" "} 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: <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> tags look like:
</p> </p>
<span className="mt-2 flex flex-wrap gap-1.5"> <span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => ( {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"> <span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600">
{tag} {tag}
</span> </span>
))} ))}
@@ -56,12 +56,12 @@ export function StepAiFeatures() {
</div> </div>
<div className="shrink-0"> <div className="shrink-0">
{taggerReady ? ( {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"> <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 light-theme:border-emerald-600/40 light-theme:bg-emerald-100 light-theme:text-emerald-700">
Installed Installed
</span> </span>
) : ( ) : (
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45" className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void prepareTaggerModel()} onClick={() => void prepareTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
@@ -84,7 +84,7 @@ export function StepAiFeatures() {
</div> </div>
) : null} ) : null}
{!taggerModelPreparing && taggerModelError ? ( {!taggerModelPreparing && taggerModelError ? (
<p className="mt-2 text-xs leading-relaxed text-amber-300/90"> <p className="mt-2 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">
Download failed: {taggerModelError} Download failed: {taggerModelError}
</p> </p>
) : null} ) : null}
@@ -92,11 +92,11 @@ export function StepAiFeatures() {
</div> </div>
<h4 className="mt-6 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Semantic search & similarity built in</h4> <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="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<div className="py-4"> <div className="py-4">
<p className="text-sm text-white">Search by meaning, find look-alikes</p> <p className="text-sm text-white">Search by meaning, find look-alikes</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <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, Powers <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/s</code> search,
"find similar", and the Explore view, so it's part of the standard pipeline: the CLIP model "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 (~580 MB) downloads automatically the first time embeddings run. Nothing to do you'll see it
in the background-tasks bar. in the background-tasks bar.
@@ -37,14 +37,14 @@ export function StepGalleryPreview() {
carry your favorites, star ratings, and video durations; hover for filename and quick actions. carry your favorites, star ratings, and video durations; hover for filename and quick actions.
</p> </p>
<div className="mt-5 grid grid-cols-4 gap-1.5"> <div className="media-dark-surface mt-5 grid grid-cols-4 gap-1.5">
{TILE_PROPS.map((props, i) => ( {TILE_PROPS.map((props, i) => (
<FakeTile key={i} index={i} loaded={i < revealed} {...props} /> <FakeTile key={i} index={i} loaded={i < revealed} {...props} />
))} ))}
</div> </div>
<div className="mt-6 flex items-start justify-between gap-4"> <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"> <div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<p className="pb-2.5"> <p className="pb-2.5">
<span className="text-gray-300">Click any tile</span> to open the lightbox keyboard navigation, <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. zoom, inline tag editing, ratings, and a full video player.
+3 -3
View File
@@ -49,7 +49,7 @@ export function StepPipeline() {
</p> </p>
{/* Fake BackgroundTasks slim bar */} {/* 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="mt-3 rounded-lg border border-white/[0.07] bg-gray-900/30 px-4 py-3 light-theme:border-gray-300/70 light-theme:bg-gray-900">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="relative flex h-1.5 w-1.5 shrink-0"> <span className="relative flex h-1.5 w-1.5 shrink-0">
{!finished ? ( {!finished ? (
@@ -57,7 +57,7 @@ export function StepPipeline() {
) : null} ) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} /> <span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${finished ? "bg-emerald-400" : "bg-blue-400"}`} />
</span> </span>
<span className="text-[13px] font-medium text-white/60">Holiday Photos</span> <span className="text-[13px] font-medium text-white/60 light-theme:text-gray-500">Holiday Photos</span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{STAGES.map((stage, i) => ( {STAGES.map((stage, i) => (
<FakeStageTag <FakeStageTag
@@ -72,7 +72,7 @@ export function StepPipeline() {
</div> </div>
<div className="mt-6 flex items-start justify-between gap-4"> <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"> <div className="divide-y divide-white/[0.05] text-xs leading-relaxed text-gray-500 light-theme:divide-gray-300/70">
<p className="pb-2.5"> <p className="pb-2.5">
<span className="text-gray-300">It's all interruptible.</span> Close the app whenever you like <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. the queue picks up where it left off next launch.
+5 -5
View File
@@ -64,12 +64,12 @@ export function StepSearchDemo() {
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <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 One search bar, three modes picked by prefix. No prefix searches filenames, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/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. by meaning, <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> searches tags.
</p> </p>
{/* Mock search bar */} {/* 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"> <div className="mt-5 flex items-center gap-2.5 rounded-lg border border-white/10 bg-gray-900/50 px-3.5 py-2.5 light-theme:border-gray-300/70 light-theme:bg-gray-900">
<svg className="h-4 w-4 shrink-0 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
@@ -77,7 +77,7 @@ export function StepSearchDemo() {
{demo.query.slice(0, typed)} {demo.query.slice(0, typed)}
{!finished ? <span className="animate-pulse text-gray-500">|</span> : null} {!finished ? <span className="animate-pulse text-gray-500">|</span> : null}
</span> </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"> <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 light-theme:border-sky-600/40 light-theme:bg-sky-100 light-theme:text-sky-700">
{demo.mode} {demo.mode}
</span> </span>
</div> </div>
@@ -90,7 +90,7 @@ export function StepSearchDemo() {
demo's visible state. */} demo's visible state. */}
<div <div
key={demoIndex} key={demoIndex}
className={`mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`} className={`media-dark-surface mt-3 flex flex-wrap justify-center gap-1.5 transition-opacity duration-300 ${fullyTyped ? "opacity-100" : "opacity-0"}`}
> >
{demo.results.map((src, i) => ( {demo.results.map((src, i) => (
<div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]"> <div key={i} className="aspect-square w-36 overflow-hidden rounded-xl bg-white/[0.04]">
+9 -9
View File
@@ -24,9 +24,9 @@ export function StepUpdates() {
</p> </p>
{/* Mini app-window mockup: the lit mark shown in a real title bar. */} {/* 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="mt-3 overflow-hidden rounded-lg border border-white/10 bg-gray-950 shadow-lg light-theme:border-gray-300/70">
<div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2"> <div className="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2 light-theme:border-gray-300/70">
<div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-white/[0.08] text-gray-300"> <div className="relative flex h-6 w-6 items-center justify-center rounded-md bg-gray-900 text-gray-300 light-theme:bg-gray-800">
<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.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" /> <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" /> <PhokusMark className="relative h-[18px] w-[18px]" dotClassName="fill-amber-400" />
@@ -48,16 +48,16 @@ export function StepUpdates() {
{/* Ghosted window body, just enough to read as the app. */} {/* Ghosted window body, just enough to read as the app. */}
<div className="flex h-[72px] bg-gray-900/30"> <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="w-14 shrink-0 border-r border-white/[0.05] p-2.5 light-theme:border-gray-300/70">
<div className="h-1.5 w-full rounded bg-white/[0.07]" /> <div className="h-1.5 w-full rounded bg-gray-800" />
<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-gray-900" />
<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-gray-900" />
</div> </div>
<div className="flex-1 p-2.5"> <div className="flex-1 p-2.5">
<div className="h-2 w-20 rounded bg-white/[0.06]" /> <div className="h-2 w-20 rounded bg-gray-800" />
<div className="mt-2.5 grid grid-cols-5 gap-1.5"> <div className="mt-2.5 grid grid-cols-5 gap-1.5">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-7 rounded bg-white/[0.04]" /> <div key={i} className="h-7 rounded bg-gray-900" />
))} ))}
</div> </div>
</div> </div>
+4 -4
View File
@@ -22,7 +22,7 @@ function ExplorePreview() {
{ size: 16, x: 70, y: 44, i: 6 }, { size: 16, x: 70, y: 44, i: 6 },
]; ];
return ( return (
<div className="relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]"> <div className="media-dark-surface relative h-[72px] w-32 overflow-hidden rounded-lg border border-white/[0.07] bg-white/[0.02]">
{blobs.map((blob, idx) => ( {blobs.map((blob, idx) => (
<div <div
key={idx} key={idx}
@@ -36,7 +36,7 @@ function ExplorePreview() {
function TimelinePreview() { function TimelinePreview() {
return ( 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"> <div className="media-dark-surface 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) => ( {[2024, 2023].map((year, row) => (
<div key={year} className="flex items-center gap-1.5"> <div key={year} className="flex items-center gap-1.5">
<span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span> <span className="w-7 text-[9px] tabular-nums text-gray-600">{year}</span>
@@ -51,7 +51,7 @@ function TimelinePreview() {
function DuplicatesPreview() { function DuplicatesPreview() {
return ( 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="media-dark-surface 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"> <div className="w-10">
<FakeTile index={3} className="rounded-md" /> <FakeTile index={3} className="rounded-md" />
</div> </div>
@@ -69,7 +69,7 @@ export function StepViews() {
<p className="text-sm leading-relaxed text-gray-300"> <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: Beyond the main grid, the sidebar switches between three more ways to look at your library:
</p> </p>
<div className="mt-3 divide-y divide-white/[0.05]"> <div className="mt-3 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<ViewRow <ViewRow
title="Explore" title="Explore"
description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster." description="A visual cluster map and tag cloud — browse by theme instead of folder, and jump into any cluster."
+86 -5
View File
@@ -1,6 +1,52 @@
import { useGalleryStore } from "../../store"; import { AppTheme, useGalleryStore } from "../../store";
import { FakeProgressBar, formatBytes } from "./fakes"; import { FakeProgressBar, formatBytes } from "./fakes";
const THEME_OPTIONS: {
value: AppTheme;
name: string;
colors: {
background: string;
surface: string;
text: string;
muted: string;
accent: string;
};
}[] = [
{
value: "phokus",
name: "Phokus",
colors: {
background: "#030712",
surface: "#111827",
text: "#f9fafb",
muted: "#6b7280",
accent: "#10b981",
},
},
{
value: "conventional-dark",
name: "Conventional Dark",
colors: {
background: "#1f1f1f",
surface: "#303030",
text: "#a3a3a3",
muted: "#666666",
accent: "#10b981",
},
},
{
value: "subtle-light",
name: "Subtle Light",
colors: {
background: "#e9e7e2",
surface: "#dedbd4",
text: "#18202c",
muted: "#817b72",
accent: "#059669",
},
},
];
export function FfmpegStatusRow() { export function FfmpegStatusRow() {
const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus); const ffmpegStatus = useGalleryStore((s) => s.ffmpegStatus);
const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress); const ffmpegProgress = useGalleryStore((s) => s.ffmpegProgress);
@@ -10,7 +56,7 @@ export function FfmpegStatusRow() {
if (ffmpegStatus === "installed") { if (ffmpegStatus === "installed") {
return ( return (
<div className="flex items-center gap-2.5 py-3"> <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}> <svg className="h-4 w-4 shrink-0 text-emerald-400 light-theme:text-emerald-700" 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" /> <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> </svg>
<div> <div>
@@ -27,14 +73,14 @@ export function FfmpegStatusRow() {
<div className="flex items-start justify-between gap-6"> <div className="flex items-start justify-between gap-6">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">Media engine download failed</p> <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 text-xs leading-relaxed text-amber-300/90 light-theme:text-amber-700">{ffmpegError}</p>
<p className="mt-1.5 text-xs leading-relaxed text-gray-500"> <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 You can keep going photos work without it. Video thumbnails and metadata will start
automatically once the download succeeds. automatically once the download succeeds.
</p> </p>
</div> </div>
<button <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" 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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void retryFfmpegDownload()} onClick={() => void retryFfmpegDownload()}
> >
Retry download Retry download
@@ -71,6 +117,9 @@ export function FfmpegStatusRow() {
} }
export function StepWelcome() { export function StepWelcome() {
const theme = useGalleryStore((s) => s.theme);
const setTheme = useGalleryStore((s) => s.setTheme);
return ( return (
<div> <div>
<p className="text-sm leading-relaxed text-gray-300"> <p className="text-sm leading-relaxed text-gray-300">
@@ -83,8 +132,40 @@ export function StepWelcome() {
from Settings. from Settings.
</p> </p>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">Choose your look</h4>
<div className="mt-3 grid grid-cols-3 gap-2">
{THEME_OPTIONS.map((option) => {
const selected = option.value === theme;
return (
<button
key={option.value}
type="button"
className={`rounded-md border p-2 text-left transition-colors ${
selected
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-400/40"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
}`}
onClick={() => setTheme(option.value)}
>
<span
className="block overflow-hidden rounded border border-black/20 p-1.5"
style={{ backgroundColor: option.colors.background }}
>
<span className="mb-1 block h-2 w-8 rounded-sm" style={{ backgroundColor: option.colors.accent }} />
<span className="block rounded-sm p-1.5" style={{ backgroundColor: option.colors.surface }}>
<span className="block h-1.5 w-9 rounded-sm" style={{ backgroundColor: option.colors.text }} />
<span className="mt-1 block h-1.5 w-14 rounded-sm" style={{ backgroundColor: option.colors.muted }} />
<span className="mt-1 block h-1.5 w-10 rounded-sm" style={{ backgroundColor: option.colors.muted }} />
</span>
</span>
<span className="mt-2 block text-[11px] font-medium">{option.name}</span>
</button>
);
})}
</div>
<h4 className="mt-7 text-[12px] font-semibold uppercase tracking-[0.08em] text-gray-400">First-time setup</h4> <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]"> <div className="mt-1 divide-y divide-white/[0.05] light-theme:divide-gray-300/70">
<FfmpegStatusRow /> <FfmpegStatusRow />
</div> </div>
</div> </div>
+6 -6
View File
@@ -82,7 +82,7 @@ export function FakeTile({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={`group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}> <div className={`media-dark-surface group relative aspect-square overflow-hidden rounded-xl bg-white/[0.04] ${className}`}>
{loaded ? ( {loaded ? (
<img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" /> <img src={fakeImage(index)} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
) : ( ) : (
@@ -116,16 +116,16 @@ export function FakeTile({
export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) { export function FakeStageTag({ label, state }: { label: string; state: "active" | "done" | "waiting" }) {
const className = const className =
state === "active" state === "active"
? "bg-white/5 text-gray-300" ? "bg-gray-900 text-gray-300 light-theme:bg-gray-900 light-theme:text-gray-300"
: state === "done" : state === "done"
? "bg-emerald-500/10 text-emerald-400" ? "bg-emerald-500/10 text-emerald-400 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "bg-white/4 text-gray-600"; : "bg-gray-900/60 text-gray-600 light-theme:bg-gray-800 light-theme:text-gray-500";
return <span className={`rounded-md px-2 py-0.5 text-[11px] ${className}`}>{label}</span>; 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 }) { export function FakeProgressBar({ fraction, className = "" }: { fraction: number | null; className?: string }) {
return ( return (
<div className={`h-px overflow-hidden rounded-full bg-white/8 ${className}`}> <div className={`h-px overflow-hidden rounded-full bg-gray-200/60 light-theme:bg-gray-300 ${className}`}>
{fraction === null ? ( {fraction === null ? (
<div className="h-full w-full animate-pulse bg-blue-500/40" /> <div className="h-full w-full animate-pulse bg-blue-500/40" />
) : ( ) : (
@@ -143,7 +143,7 @@ export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => voi
<button <button
type="button" type="button"
onClick={onClick} 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" 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 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
> >
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}> <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" /> <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" />
+247 -3
View File
@@ -1,5 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant light-theme (&:is(html[data-theme="subtle-light"] *));
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@@ -10,11 +12,253 @@ body,
height: 100%; height: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
background: #030712; background: var(--color-gray-950);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
html[data-theme="conventional-dark"] {
--color-gray-950: #1f1f1f;
--color-gray-900: #252525;
--color-gray-800: #303030;
--color-gray-700: #444444;
--color-gray-600: #666666;
--color-gray-500: #858585;
--color-gray-400: #a3a3a3;
--color-gray-300: #c4c4c4;
--color-gray-200: #dddddd;
--color-gray-100: #eeeeee;
background: #1f1f1f;
}
html[data-theme="subtle-light"] {
--color-white: #18202c;
--color-gray-950: #e9e7e2;
--color-gray-900: #dedbd4;
--color-gray-800: #cfcbc3;
--color-gray-700: #aea99f;
--color-gray-600: #817b72;
--color-gray-500: #655f57;
--color-gray-400: #514b44;
--color-gray-300: #403b35;
--color-gray-200: #302c28;
--color-gray-100: #24211e;
/* Pastel accent shades are tuned for dark UIs and wash out on the light
chrome (e.g. "Update check failed" in amber). Darken the ones used as
coloured text/icons so they stay readable. Media surfaces reset these to
the bright originals below, so on-photo overlays keep their signal colours.
Remapping the variable (not the utility) also covers opacity variants such
as text-amber-300/90. */
--color-amber-200: #b45309;
--color-amber-300: #b45309;
--color-amber-400: #b45309;
--color-red-200: #b91c1c;
--color-red-300: #b91c1c;
--color-red-400: #b91c1c;
--color-rose-300: #be123c;
--color-rose-400: #be123c;
--color-emerald-200: #047857;
--color-emerald-300: #047857;
--color-emerald-400: #047857;
--color-sky-300: #0369a1;
--color-blue-300: #1d4ed8;
--color-blue-400: #1d4ed8;
--color-violet-300: #6d28d9;
--color-violet-400: #6d28d9;
background: #e9e7e2;
}
html[data-theme="subtle-light"] .media-dark-surface {
--color-white: #ffffff;
--color-black: #000000;
--color-gray-950: #030712;
--color-gray-900: #111827;
--color-gray-800: #1f2937;
--color-gray-700: #374151;
--color-gray-600: #4b5563;
--color-gray-500: #6b7280;
--color-gray-400: #9ca3af;
--color-gray-300: #d1d5db;
--color-gray-200: #e5e7eb;
--color-gray-100: #f3f4f6;
/* Restore the bright accent originals the light theme darkened, so coloured
overlays on photos/video (ratings, failed badges, the lightbox region tool)
keep their signal colours instead of going dark-on-dark. */
--color-amber-200: oklch(92.4% 0.12 95.746);
--color-amber-300: oklch(87.9% 0.169 91.605);
--color-amber-400: oklch(82.8% 0.189 84.429);
--color-red-200: oklch(88.5% 0.062 18.334);
--color-red-300: oklch(80.8% 0.114 19.571);
--color-red-400: oklch(70.4% 0.191 22.216);
--color-rose-300: oklch(81% 0.117 11.638);
--color-rose-400: oklch(71.2% 0.194 13.428);
--color-emerald-200: oklch(90.5% 0.093 164.15);
--color-emerald-300: oklch(84.5% 0.143 164.978);
--color-emerald-400: oklch(76.5% 0.177 163.223);
--color-sky-300: oklch(82.8% 0.111 230.318);
--color-blue-300: oklch(80.9% 0.105 251.813);
--color-blue-400: oklch(70.7% 0.165 254.624);
--color-violet-300: oklch(81.1% 0.111 293.571);
--color-violet-400: oklch(70.2% 0.183 293.541);
}
/* The lightbox keeps its dark canvas + backdrop (via .media-dark-surface on the
root), but the metadata panel is chrome and should follow the active theme.
In subtle-light it lives inside the dark-surface root, so re-light it here by
remapping the palette on the panel itself. Tailwind v4 resolves every colour
utility through a --color-* variable, so remapping the variables re-themes the
whole subtree including accents without a single !important. */
html[data-theme="subtle-light"] .lightbox-panel {
--color-white: #18202c;
--color-gray-950: #e9e7e2;
--color-gray-900: #dedbd4;
--color-gray-800: #cfcbc3;
--color-gray-700: #aea99f;
--color-gray-600: #817b72;
--color-gray-500: #655f57;
--color-gray-400: #514b44;
--color-gray-300: #403b35;
--color-gray-200: #302c28;
--color-gray-100: #24211e;
/* Pastel accents are tuned for a dark panel; darken them for the light one. */
--color-amber-300: #b45309;
--color-amber-400: #b45309;
--color-sky-300: #0369a1;
--color-emerald-300: #047857;
--color-rose-300: #be123c;
--color-rose-400: #be123c;
--color-red-300: #b91c1c;
--color-violet-300: #6d28d9;
--color-violet-400: #6d28d9;
}
html[data-theme="subtle-light"] .explore-view {
background:
radial-gradient(ellipse at top, rgb(59 130 246 / 0.08), transparent 48%),
radial-gradient(ellipse at 80% 75%, rgb(16 185 129 / 0.07), transparent 42%),
#f4f2ea !important;
color: #1f2937 !important;
}
html[data-theme="subtle-light"] .explore-header {
background: #f4f2ea !important;
border-color: #d8d2c7 !important;
}
html[data-theme="subtle-light"] .explore-title {
color: #111827 !important;
}
html[data-theme="subtle-light"] .explore-subtitle,
html[data-theme="subtle-light"] .explore-empty {
color: #7a746b !important;
}
html[data-theme="subtle-light"] .explore-mode-toggle {
background: #ece8dd !important;
border-color: #d0c8ba !important;
}
html[data-theme="subtle-light"] .explore-mode-button {
background: transparent !important;
color: #4b5563 !important;
}
html[data-theme="subtle-light"] .explore-mode-button:hover {
background: #e0dbcf !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .explore-mode-button.bg-white\/10 {
background: #d8d4ca !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .explore-cluster-grid {
background-image: radial-gradient(circle, rgb(31 41 55 / 0.18) 1px, transparent 1px) !important;
opacity: 0.16 !important;
}
html[data-theme="subtle-light"] .explore-cluster-card {
background: #fbfaf6 !important;
border-color: #d8d2c7 !important;
box-shadow: 0 14px 36px rgb(28 25 23 / 0.18) !important;
}
html[data-theme="subtle-light"] .explore-cluster-card:hover {
box-shadow: 0 18px 44px rgb(28 25 23 / 0.22) !important;
}
html[data-theme="subtle-light"] .explore-cluster-overlay {
/* A cream wash over the photo read as a faded, foggy haze. Use a gentle dark
scrim instead the same natural "photo caption" vignette the dark theme
uses with light text on top (label/count rules below). */
background: linear-gradient(
to top,
rgb(17 18 22 / 0.82),
rgb(17 18 22 / 0.34) 38%,
transparent 66%
) !important;
}
html[data-theme="subtle-light"] .explore-cluster-label {
color: rgb(255 255 255 / 0.62) !important;
}
html[data-theme="subtle-light"] .explore-cluster-count {
color: #ffffff !important;
}
html[data-theme="subtle-light"] .explore-tag-word:hover {
background: #e8e2d6 !important;
}
html[data-theme="subtle-light"] .explore-spinner {
border-color: rgb(17 24 39 / 0.18) !important;
border-top-color: rgb(17 24 39 / 0.55) !important;
}
html[data-theme="subtle-light"] .feature-scope-trigger {
background: #f8f6ef !important;
border-color: #d0c8ba !important;
color: #4b5563 !important;
}
html[data-theme="subtle-light"] .feature-scope-trigger:hover,
html[data-theme="subtle-light"] .feature-scope-dropdown:has(.feature-scope-menu) .feature-scope-trigger {
background: #ece8dd !important;
border-color: #bfb6a7 !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .feature-scope-menu {
background: #fbfaf6 !important;
border-color: #d0c8ba !important;
color: #1f2937 !important;
box-shadow: 0 18px 44px rgb(28 25 23 / 0.2) !important;
}
html[data-theme="subtle-light"] .feature-scope-option {
color: #4b5563 !important;
}
html[data-theme="subtle-light"] .feature-scope-option:hover {
background: #ece8dd !important;
color: #111827 !important;
}
html[data-theme="subtle-light"] .feature-scope-option.bg-white\/6 {
background: #d8d4ca !important;
color: #111827 !important;
}
@media (prefers-reduced-motion: reduce) {
.explore-cluster-card,
.explore-cluster-card img {
transition-duration: 0.01ms !important;
}
}
/* Custom scrollbar */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -24,9 +268,9 @@ body,
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1); background: color-mix(in srgb, var(--color-white, #fff) 12%, transparent);
border-radius: 3px; border-radius: 3px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2); background: color-mix(in srgb, var(--color-white, #fff) 22%, transparent);
} }
+738 -48
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const SECTION_BY_TYPE = {
added: "Added",
changed: "Changed",
deprecated: "Deprecated",
removed: "Removed",
fixed: "Fixed",
security: "Security",
};
function usage() {
console.error([
"Usage:",
" pnpm changelog:add -- --type fixed --message \"Fix thing\"",
"",
`Types: ${Object.keys(SECTION_BY_TYPE).join(", ")}`,
].join("\n"));
process.exit(1);
}
function argValue(name) {
const index = process.argv.indexOf(`--${name}`);
if (index === -1) return null;
return process.argv[index + 1] ?? null;
}
const type = argValue("type")?.toLowerCase();
const message = argValue("message")?.trim();
if (!type || !message || !SECTION_BY_TYPE[type]) {
usage();
}
const section = SECTION_BY_TYPE[type];
const changelogPath = resolve("CHANGELOG.md");
let changelog = readFileSync(changelogPath, "utf8");
if (!/^## \[Unreleased\]/m.test(changelog)) {
changelog = changelog.replace(
/(\r?\n)(## \[[^\r\n]+\])/,
`$1## [Unreleased]$1$1$2`,
);
}
// JS regex has no \z anchor, so match to the next version heading or true
// end-of-input ($ with nothing following — \n-tolerant under the m flag).
const unreleasedMatch = changelog.match(/^## \[Unreleased\][\s\S]*?(?=^## \[|$(?![\s\S]))/m);
if (!unreleasedMatch) {
throw new Error("Could not find or create an Unreleased section.");
}
const unreleased = unreleasedMatch[0];
const lineEnding = changelog.includes("\r\n") ? "\r\n" : "\n";
const bullet = `- ${message}`;
let nextUnreleased;
const sectionRegex = new RegExp(`(^### ${section}\\s*)([\\s\\S]*?)(?=^### |$(?![\\s\\S]))`, "m");
const sectionMatch = unreleased.match(sectionRegex);
if (sectionMatch) {
const body = sectionMatch[2].trimEnd();
const replacement = `${sectionMatch[1]}${body ? `${body}${lineEnding}` : ""}${bullet}${lineEnding}${lineEnding}`;
nextUnreleased = unreleased.replace(sectionRegex, replacement);
} else {
const insert = `${lineEnding}### ${section}${lineEnding}${lineEnding}${bullet}${lineEnding}`;
nextUnreleased = unreleased.trimEnd() + insert + lineEnding;
}
changelog = changelog.replace(unreleased, nextUnreleased);
writeFileSync(changelogPath, changelog);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/brand/phokus-mark.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Phokus — your local media library, made searchable</title>
<meta
name="description"
content="Phokus is a local-first desktop media library for Windows. Browse, search by meaning, and curate large image and video folders with semantic search, visual discovery, and on-device AI. Nothing leaves your machine."
/>
<meta name="theme-color" content="#0a0b0d" />
<!-- Open Graph (social card image added in the metadata pass) -->
<meta property="og:title" content="Phokus — your local media library, made searchable" />
<meta
property="og:description"
content="Semantic search, visual discovery, and duplicate cleanup for the image and video folders already on your computer. Local-first, Windows."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://phokus.jezz.wtf" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@phokus/website",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/inter": "5.2.8",
"@fontsource-variable/space-grotesk": "5.2.10",
"framer-motion": "^12.38.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.8.3",
"vite": "^7.0.4",
"vite-imagetools": "^10.0.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
<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>
<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">
<circle r="110"/>
<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"/>
<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>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+752
View File
@@ -0,0 +1,752 @@
import { useRef, useState } from "react";
import { PhokusMark } from "./components/PhokusMark";
// Screenshots are 2560×1600 masters; imagetools downscales + transcodes to
// AVIF/WebP at build (see the Shot helper). `as=picture` must be the last query
// param so the `*as=picture` module declaration matches.
import heroShot from "../assets/screenshots/phokus_galleryHero.jpg?w=1600&format=avif;webp&quality=50&as=picture";
import semanticShot from "../assets/screenshots/phokus_semanticSearch.jpg?w=1600&format=avif;webp&quality=50&as=picture";
import exploreShot from "../assets/screenshots/phokus_exploreClusters.png?w=1600&format=avif;webp&quality=50&as=picture";
import timelineShot from "../assets/screenshots/phokus_timeline.jpg?w=1600&format=avif;webp&quality=50&as=picture";
import lightboxShot from "../assets/screenshots/phokus_lightboxWithTags.jpg?w=1600&format=avif;webp&quality=50&as=picture";
import videoShot from "../assets/screenshots/phokus_videoPlayer.jpg?w=1600&format=avif;webp&quality=50&as=picture";
import duplicatesShot from "../assets/screenshots/phokus_duplicates.png?w=1600&format=avif;webp&quality=50&as=picture";
const REPO_URL = "https://github.com/JezzWTF/phokus";
const DOWNLOAD_URL = "https://github.com/JezzWTF/phokus/releases/latest";
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d="M12 .5C5.73.5.5 5.73.5 12c0 5.08 3.29 9.39 7.86 10.91.58.11.79-.25.79-.56v-2c-3.2.7-3.88-1.54-3.88-1.54-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.2 1.77 1.2 1.03 1.77 2.71 1.26 3.37.96.1-.75.4-1.26.73-1.55-2.56-.29-5.25-1.28-5.25-5.7 0-1.26.45-2.29 1.19-3.1-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.18 1.18a11 11 0 0 1 5.8 0c2.2-1.49 3.17-1.18 3.17-1.18.63 1.59.23 2.76.12 3.05.74.81 1.18 1.84 1.18 3.1 0 4.43-2.69 5.41-5.26 5.69.41.36.78 1.06.78 2.14v3.17c0 .31.21.68.8.56A11.51 11.51 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5Z" />
</svg>
);
}
// Renders an imagetools `?as=picture` import: AVIF + WebP sources with the WebP
// fallback on the <img>. `display:contents` so the layout/rounding lives on the
// <img>, exactly as a bare <img> would. width/height come from the transcode to
// reserve space and avoid layout shift.
type PictureImport = { sources: Record<string, string>; img: { src: string; w: number; h: number } };
function Shot({
pic,
alt,
className,
ariaHidden,
}: {
pic: PictureImport;
alt: string;
className?: string;
ariaHidden?: boolean;
}) {
return (
<picture className="contents">
{Object.entries(pic.sources).map(([type, srcSet]) => (
<source key={type} type={type} srcSet={srcSet} />
))}
<img
src={pic.img.src}
width={pic.img.w}
height={pic.img.h}
alt={alt}
aria-hidden={ariaHidden}
className={className}
/>
</picture>
);
}
function Header() {
return (
<header className="sticky top-0 z-50 border-b border-edge bg-canvas/70 backdrop-blur-md">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-5 lg:h-16 lg:px-6">
<a href="#top" className="flex items-center gap-2.5 text-ink">
<PhokusMark className="h-5 w-5 text-amber lg:h-6 lg:w-6" />
<span className="font-display font-semibold tracking-tight lg:text-lg">Phokus</span>
</a>
<nav className="hidden items-center gap-8 text-sm text-muted md:flex">
<a href="#search" className="transition-colors hover:text-ink">Search</a>
<a href="#explore" className="transition-colors hover:text-ink">Explore</a>
<a href="#privacy" className="transition-colors hover:text-ink">Privacy</a>
<a href={REPO_URL} className="transition-colors hover:text-ink">Source</a>
</nav>
<a
href={DOWNLOAD_URL}
className="hidden rounded-lg border border-edge-strong px-4 py-2 text-sm font-medium text-ink transition-colors hover:bg-surface md:block"
>
Download
</a>
<a
href={REPO_URL}
aria-label="View Phokus source on GitHub"
className="grid h-9 w-9 place-items-center rounded-full border border-edge-strong text-muted transition-colors hover:bg-surface hover:text-ink md:hidden"
>
<GitHubIcon className="h-4 w-4" />
</a>
</div>
</header>
);
}
function MobileChapterNav() {
return (
<nav
aria-label="Page sections"
className="sticky top-14 z-40 flex gap-1 overflow-x-auto border-b border-edge bg-canvas/90 px-4 py-2 backdrop-blur-md lg:hidden"
>
{[
["Search", "#search"],
["Features", "#features"],
["Details", "#tech"],
["Download", "#download"],
].map(([label, href]) => (
<a
key={href}
href={href}
className="shrink-0 rounded-full px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
{label}
</a>
))}
</nav>
);
}
function MobileChapter({
number,
label,
title,
children,
}: {
number: string;
label: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="lg:hidden">
<div className="flex items-center gap-3 text-[0.68rem] font-semibold uppercase tracking-[0.18em] text-faint">
<span className="font-mono text-amber">{number}</span>
<span className="h-px w-8 bg-edge-strong" />
{label}
</div>
<h2 className="mt-3 max-w-[20rem] font-display text-[1.75rem] font-semibold leading-[1.08] tracking-tight text-ink">
{title}
</h2>
{children}
</div>
);
}
function Hero() {
return (
<section id="top" className="relative overflow-hidden">
<div className="lg:hidden">
<div className="relative h-[34svh] min-h-64 overflow-hidden border-b border-edge">
<Shot
pic={heroShot}
alt="The Phokus gallery showing a dense library of landscape photos."
className="absolute inset-0 h-full w-full object-cover object-[67%_top]"
/>
<div className="absolute inset-0 bg-gradient-to-b from-canvas/20 via-transparent to-canvas" />
<div className="absolute inset-x-5 top-5 flex items-center justify-between">
<span className="rounded-full border border-white/10 bg-black/45 px-3 py-1.5 text-[0.68rem] font-medium text-white/80 backdrop-blur">
Your folders. Your machine.
</span>
<span className="flex items-center gap-1.5 text-[0.68rem] text-white/60">
<span className="h-1.5 w-1.5 rounded-full bg-amber" />
Windows
</span>
</div>
</div>
<div className="relative -mt-7 px-5 pb-12">
<h1 className="max-w-[21rem] font-display text-[2.45rem] font-semibold leading-[0.98] tracking-[-0.035em] text-ink">
See everything.
<br />
Find anything.
</h1>
<p className="mt-4 max-w-[22rem] text-[0.95rem] leading-6 text-muted">
Search and curate the images and videos already on your computer. Everything stays local.
</p>
<div className="mt-6">
<a
href={DOWNLOAD_URL}
className="flex min-h-12 items-center justify-between rounded-xl bg-amber px-5 text-sm font-semibold text-canvas transition-colors hover:bg-amber-soft"
>
Download for Windows
<span aria-hidden="true"></span>
</a>
</div>
<div className="mt-5 grid grid-cols-3 divide-x divide-edge border-y border-edge py-4 text-center">
<span className="px-2 text-[0.68rem] leading-4 text-faint">
<strong className="block font-medium text-ink">Local</strong>
no uploads
</span>
<span className="px-2 text-[0.68rem] leading-4 text-faint">
<strong className="block font-medium text-ink">Private</strong>
no account
</span>
<span className="px-2 text-[0.68rem] leading-4 text-faint">
<strong className="block font-medium text-ink">Offline</strong>
after setup
</span>
</div>
</div>
</div>
<div className="relative hidden min-h-[86vh] items-center lg:flex">
{/* Product leads: the gallery dominates the right and bleeds off the frame,
cropped so the workspace appears to continue beyond the viewport. */}
<div className="pointer-events-none absolute inset-y-0 right-0 w-[62%]">
<Shot
pic={heroShot}
alt=""
ariaHidden
className="h-full w-full object-cover object-left-top"
/>
<div className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-canvas)_0%,transparent_46%)]" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-canvas to-transparent" />
</div>
<div className="pointer-events-none absolute -left-24 top-8 -z-10 h-96 w-96 rounded-full bg-amber/[0.06] blur-3xl" />
<div className="relative mx-auto w-full max-w-7xl px-6 py-24">
<div className="max-w-2xl">
<p className="mb-6 inline-flex items-center gap-2 rounded-full border border-edge bg-surface/80 px-3 py-1 text-xs font-medium text-muted backdrop-blur">
<span className="h-1.5 w-1.5 rounded-full bg-amber" />
Local-first · Windows desktop
</p>
<h1 className="font-display text-6xl font-semibold leading-[1.04] tracking-tight text-ink">
Your local media library,
<br />
made searchable.
</h1>
<p className="mt-6 max-w-lg text-lg leading-relaxed text-muted">
Browse, understand, and curate large image and video folders semantic search, visual
discovery, and duplicate cleanup over the files already on your computer.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<a
href={DOWNLOAD_URL}
className="rounded-lg bg-amber px-5 py-2.5 text-sm font-medium text-canvas transition-colors hover:bg-amber-soft"
>
Download for Windows
</a>
<a
href={REPO_URL}
className="inline-flex items-center gap-2 rounded-lg border border-edge-strong bg-canvas/60 px-5 py-2.5 text-sm font-medium text-ink backdrop-blur transition-colors hover:bg-surface"
>
<GitHubIcon className="h-4 w-4" />
View source
</a>
</div>
<p className="mt-5 text-sm text-faint">
Windows 10/11 · Open source · On-device processing · No account
</p>
</div>
</div>
</div>
</section>
);
}
function LocalFirst() {
const points = [
{
title: "Your files stay put",
body: "Phokus reads the folders you point it at. Media and everything it derives — thumbnails, embeddings, tags, ratings — never leave your machine.",
},
{
title: "On-device intelligence",
body: "Semantic search and AI tagging run locally. The models download once on first use; after that, search works offline.",
},
{
title: "No account, no cloud",
body: "No sign-up, no telemetry, no server. The only network traffic is the one-time model downloads and checking for updates.",
},
];
return (
<section id="privacy" className="relative hidden overflow-hidden border-y border-edge bg-surface lg:block">
<EdgeMark side="left" />
<div className="relative mx-auto max-w-7xl px-6 py-28">
<div className="max-w-2xl">
<SectionEyebrow label="Local-first" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
A library that stays on your machine.
</h2>
<p className="mt-5 text-lg leading-relaxed text-muted">
Phokus runs entirely on your computer. Nothing is uploaded, and there's nothing to sign
into your photos and everything Phokus learns about them stay with you.
</p>
</div>
<div className="mt-12 grid gap-px overflow-hidden rounded-xl border border-edge bg-edge sm:grid-cols-3">
{points.map((p) => (
<div key={p.title} className="bg-surface p-6">
<h3 className="text-base font-medium text-ink">{p.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">{p.body}</p>
</div>
))}
</div>
</div>
</section>
);
}
function SectionEyebrow({ label }: { label: string }) {
return (
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-faint">
<PhokusMark className="h-4 w-4 text-muted" dotClassName="fill-amber" />
{label}
</span>
);
}
function SearchSection() {
return (
<section id="search" className="relative overflow-hidden">
<EdgeMark side="right" />
<div className="relative mx-auto max-w-7xl px-5 py-12 lg:px-6 lg:py-28">
<MobileChapter number="02" label="Semantic search" title="Describe the image. Skip the filename.">
<div className="mt-6 flex min-h-11 items-center gap-3 rounded-xl border border-edge-strong bg-surface px-4 shadow-lg shadow-black/20">
<span className="rounded bg-surface-2 px-1.5 py-0.5 font-mono text-xs text-amber">/s</span>
<span className="text-sm text-ink">seaside</span>
<span className="ml-auto text-xs text-faint">200 matches</span>
</div>
<div className="relative mt-3 h-80 overflow-hidden rounded-2xl border border-edge bg-surface shadow-2xl shadow-black/40">
<Shot
pic={semanticShot}
alt="Coastal photos returned for a semantic search for seaside."
className="h-full w-full object-cover object-[65%_center]"
/>
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/80 to-transparent" />
<p className="absolute bottom-4 left-4 right-4 text-xs leading-5 text-white/70">
Coastlines, waves, shells and dunes, matched by visual meaning.
</p>
</div>
<p className="mt-5 text-sm leading-6 text-muted">
Search by mood, place, subject, or visual idea. CLIP runs on-device, so it still works
offline and needs no manual tags.
</p>
</MobileChapter>
<div className="hidden lg:block">
<div className="max-w-2xl">
<SectionEyebrow label="Search by meaning" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
Find it by what it looks like, not what it's named.
</h2>
<p className="mt-5 text-lg leading-relaxed text-muted">
Type{" "}
<code className="rounded-md border border-edge bg-surface-2 px-1.5 py-0.5 font-mono text-[0.85em] text-ink">
/s seaside
</code>{" "}
and Phokus returns coastlines, breaking waves, and shells ranked by visual meaning with an
on-device CLIP model. No tags required, and it keeps working offline.
</p>
</div>
<figure className="relative mt-12 overflow-hidden rounded-xl border border-edge bg-surface shadow-2xl shadow-black/50">
<Shot
pic={semanticShot}
alt="Phokus showing a semantic search for 'seaside': a grid of coastal photos — beaches, breaking waves, shells, and a jellyfish — matched by visual meaning rather than filename."
className="block w-full"
/>
</figure>
</div>
</div>
</section>
);
}
function MobileFeatureDeck() {
const [activeCard, setActiveCard] = useState(0);
const cardRefs = useRef<Array<HTMLElement | null>>([]);
function updateActiveCard(event: React.UIEvent<HTMLDivElement>) {
const deckCenter = event.currentTarget.scrollLeft + event.currentTarget.clientWidth / 2;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
cardRefs.current.forEach((card, index) => {
if (!card) return;
const cardCenter = card.offsetLeft + card.offsetWidth / 2;
const distance = Math.abs(cardCenter - deckCenter);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
});
setActiveCard(nearestIndex);
}
function showCard(index: number) {
cardRefs.current[index]?.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "nearest",
inline: "center",
});
}
return (
<section id="features" className="overflow-hidden border-y border-edge bg-surface py-12 lg:hidden">
<div className="px-5">
<MobileChapter number="03" label="More than search" title="Three ways to move through a library.">
<p className="mt-4 text-sm leading-6 text-muted">
Swipe through the core workflows. Each one stays focused on the media, not app chrome.
</p>
</MobileChapter>
</div>
<div
onScroll={updateActiveCard}
className="mt-7 flex snap-x snap-mandatory gap-3 overflow-x-auto px-5 pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<article
ref={(node) => { cardRefs.current[0] = node; }}
className="w-[78vw] max-w-[19rem] shrink-0 snap-center overflow-hidden rounded-2xl border border-edge bg-canvas"
>
<div className="h-64 overflow-hidden">
<Shot
pic={exploreShot}
alt="Visual clusters arranged across the Explore canvas."
className="h-full w-full object-cover object-[54%_center]"
/>
</div>
<div className="p-4">
<span className="font-mono text-[0.65rem] text-amber">EXPLORE</span>
<h3 className="mt-2 font-display text-xl font-semibold text-ink">See visual clusters.</h3>
<p className="mt-2 text-sm leading-6 text-muted">
Wander through related shots, then switch to a chronological timeline.
</p>
</div>
</article>
<article
ref={(node) => { cardRefs.current[1] = node; }}
className="w-[78vw] max-w-[19rem] shrink-0 snap-center overflow-hidden rounded-2xl border border-edge bg-canvas"
>
<div className="h-64 overflow-hidden">
<Shot
pic={lightboxShot}
alt="A waterfall open in the Phokus lightbox."
className="h-full w-full object-cover object-[37%_center]"
/>
</div>
<div className="p-4">
<span className="font-mono text-[0.65rem] text-amber">CURATE</span>
<h3 className="mt-2 font-display text-xl font-semibold text-ink">Review without leaving.</h3>
<p className="mt-2 text-sm leading-6 text-muted">
Rate, favourite, tag, zoom, play video, and find similar images in place.
</p>
</div>
</article>
<article
ref={(node) => { cardRefs.current[2] = node; }}
className="w-[78vw] max-w-[19rem] shrink-0 snap-center overflow-hidden rounded-2xl border border-edge bg-canvas"
>
<div className="h-64 overflow-hidden">
<Shot
pic={duplicatesShot}
alt="Duplicate image pairs selected for safe cleanup."
className="h-full w-full object-cover object-[24%_top]"
/>
</div>
<div className="p-4">
<span className="font-mono text-[0.65rem] text-amber">CLEANUP</span>
<h3 className="mt-2 font-display text-xl font-semibold text-ink">Compare exact pairs.</h3>
<p className="mt-2 text-sm leading-6 text-muted">
Confirm byte-identical files and choose what to remove. Nothing is automatic.
</p>
</div>
</article>
</div>
<div className="flex items-center gap-2 px-5 text-[0.68rem] text-faint">
{[0, 1, 2].map((index) => (
<button
key={index}
type="button"
aria-label={`Show feature ${index + 1}`}
aria-current={activeCard === index ? "true" : undefined}
onClick={() => showCard(index)}
className={`h-1.5 rounded-full transition-[width,background-color] ${
activeCard === index ? "w-8 bg-amber" : "w-1.5 bg-edge-strong"
}`}
/>
))}
<span className="ml-1">Swipe</span>
</div>
</section>
);
}
// Recurring aperture motif. One mark per section, vertically centered and bleeding
// off the LEFT or RIGHT edge, sides alternating down the page — a clean zigzag that
// never lets two marks collide at a section boundary (corner placement did).
//
// The iris is the full mark, but with blades reconstructed to meet the rim
// *tangentially* — each blade is an arc internally tangent to the rim (radius 3.93,
// center on the line out to the tip), so it kisses the circle and peels inward to
// the opening instead of crossing it. The shipped logo's blades use radius 10
// (== rim), which reads fine at icon size but looks "broken" blown up huge; this is
// the same mark, geometrically clean at any scale.
const WM_BLADE = "M0,-4.18 A3.93,3.93 0 0 1 6.43,-7.66";
function EdgeMark({ side }: { side: "left" | "right" }) {
const place = side === "left" ? "-left-[19rem]" : "-right-[19rem]";
return (
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={`pointer-events-none absolute top-1/2 hidden h-[34rem] w-[34rem] -translate-y-1/2 text-ink opacity-[0.05] sm:block ${place}`}
>
<g
transform="translate(12 12)"
stroke="currentColor"
strokeWidth="1.1"
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={WM_BLADE} />
<path d={WM_BLADE} transform="rotate(60)" />
<path d={WM_BLADE} transform="rotate(120)" />
<path d={WM_BLADE} transform="rotate(180)" />
<path d={WM_BLADE} transform="rotate(240)" />
<path d={WM_BLADE} transform="rotate(300)" />
</g>
</svg>
);
}
function ExploreSection() {
return (
<section id="explore" className="relative hidden overflow-hidden border-t border-edge bg-surface lg:block">
<EdgeMark side="left" />
<div className="relative mx-auto max-w-7xl px-5 py-16 lg:px-6 lg:py-28">
<div className="grid items-center gap-10 lg:grid-cols-12">
<div className="lg:col-span-5">
<SectionEyebrow label="Explore & timeline" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
See the shape of everything you've shot.
</h2>
<p className="mt-5 text-lg leading-relaxed text-muted">
Explore groups your library into visual clusters sets of similar shots you can open and
wander, gathered by what they look like rather than where they're filed. A tag cloud
surfaces recurring themes, and the Timeline walks everything by the date it was taken.
</p>
</div>
<figure className="overflow-hidden rounded-xl border border-edge bg-canvas shadow-2xl shadow-black/50 lg:col-span-7">
<Shot
pic={exploreShot}
alt="The Explore view: clusters of related photos scattered across a dark canvas, each labelled with a count."
className="block w-full"
/>
</figure>
</div>
<figure className="mt-6 overflow-hidden rounded-xl border border-edge bg-canvas shadow-xl shadow-black/40">
<Shot
pic={timelineShot}
alt="The Timeline view: media grouped into month sections such as May 2024, June 2024, and July 2024."
className="block max-h-80 w-full object-cover object-top"
/>
</figure>
</div>
</section>
);
}
function CurateSection() {
return (
<section id="curate" className="relative hidden overflow-hidden lg:block">
<EdgeMark side="right" />
<div className="relative mx-auto max-w-7xl px-5 py-16 lg:px-6 lg:py-28">
<div className="grid items-center gap-10 lg:grid-cols-12">
<figure className="order-2 overflow-hidden rounded-xl border border-edge bg-surface shadow-2xl shadow-black/50 lg:order-1 lg:col-span-7">
<Shot
pic={lightboxShot}
alt="The Phokus lightbox: a waterfall photo with a details panel showing rating stars, dimensions, file size, embedding status, and AI tags."
className="block w-full"
/>
</figure>
<div className="order-1 lg:order-2 lg:col-span-5">
<SectionEyebrow label="Review & curate" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
Rate, tag, and dig in without losing your place.
</h2>
<p className="mt-5 text-lg leading-relaxed text-muted">
Open anything full-screen with zoom and pan, set ratings and favourites, edit tags, or
search within an image for look-alikes. Video gets a proper player too scrubbing,
volume, speed, and fullscreen.
</p>
</div>
</div>
<figure className="mt-6 overflow-hidden rounded-xl border border-edge bg-surface shadow-xl shadow-black/40">
<Shot
pic={videoShot}
alt="The Phokus video player: a video open full-screen with playback controls and a details panel."
className="block max-h-[26rem] w-full object-cover object-center"
/>
</figure>
</div>
</section>
);
}
function CleanupSection() {
return (
<section id="cleanup" className="relative hidden overflow-hidden border-t border-edge bg-surface lg:block">
<EdgeMark side="left" />
<div className="relative mx-auto max-w-7xl px-5 py-16 lg:px-6 lg:py-28">
<div className="max-w-2xl">
<SectionEyebrow label="Clean up safely" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
Find exact duplicates and reclaim the space.
</h2>
<p className="mt-5 text-lg leading-relaxed text-muted">
A three-pass scan size, then a sample hash, then a full hash groups byte-identical files
so you can review each set and bulk-delete with confidence. Nothing is removed without your
say-so.
</p>
</div>
<figure className="mt-12 overflow-hidden rounded-xl border border-edge bg-canvas shadow-2xl shadow-black/50">
<Shot
pic={duplicatesShot}
alt="The Duplicate Finder: groups of duplicate photos with per-group sizes, reclaimable space, and bulk-delete controls."
className="block w-full"
/>
</figure>
</div>
</section>
);
}
function TechFactsSection() {
const facts = [
{ k: "Platform", v: "Windows 10 / 11 (x64). The installer fetches the WebView2 runtime if it's missing." },
{ k: "Media", v: "Common image formats plus video — thumbnails and metadata come from a bundled FFmpeg." },
{ k: "On-device AI", v: "CLIP embeddings power semantic search (~580 MB); an optional WD tagger (~1.3 GB) adds tags." },
{ k: "Inference", v: "CPU / DirectML in the standard build; an optional CUDA build accelerates NVIDIA GPUs." },
{ k: "Storage", v: "A local SQLite database and thumbnail cache in your app-data folder. Nothing in the cloud." },
{ k: "License & build", v: "MIT, open source. The installer is currently unsigned — see the note below." },
];
return (
<section id="tech" className="relative mx-auto max-w-7xl px-5 py-12 lg:px-6 lg:py-28">
<MobileChapter number="04" label="Technical details" title="The practical bits.">
<div className="mt-6 divide-y divide-edge border-y border-edge">
{facts.map((fact, index) => (
<details key={fact.k} className="group py-1">
<summary className="flex min-h-12 cursor-pointer list-none items-center gap-3 py-2 text-sm font-medium text-ink">
<span className="font-mono text-[0.68rem] text-faint">0{index + 1}</span>
{fact.k}
<span className="ml-auto text-lg font-light text-amber transition-transform group-open:rotate-45">+</span>
</summary>
<p className="pb-5 pl-8 text-sm leading-6 text-muted">{fact.v}</p>
</details>
))}
</div>
</MobileChapter>
<div className="hidden lg:block">
<div className="max-w-2xl">
<SectionEyebrow label="The technical facts" />
<h2 className="mt-4 font-display text-3xl font-semibold tracking-tight text-ink sm:text-4xl">
What you're actually running.
</h2>
</div>
<dl className="mt-12 grid gap-px overflow-hidden rounded-xl border border-edge bg-edge sm:grid-cols-2">
{facts.map((f) => (
<div key={f.k} className="bg-canvas p-6">
<dt className="text-sm font-medium text-ink">{f.k}</dt>
<dd className="mt-1.5 text-sm leading-relaxed text-muted">{f.v}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
function DownloadSection() {
return (
<section id="download" className="relative overflow-hidden border-t border-edge bg-surface">
<EdgeMark side="right" />
<div className="relative mx-auto max-w-3xl px-5 py-12 text-left lg:px-6 lg:py-28 lg:text-center">
<PhokusMark className="h-10 w-10 text-ink lg:mx-auto lg:h-12 lg:w-12" dotClassName="fill-amber" />
<h2 className="mt-6 max-w-xs font-display text-[2rem] font-semibold leading-tight tracking-tight text-ink lg:mx-auto lg:max-w-none lg:text-4xl">
Point Phokus at a folder.
</h2>
<p className="mt-4 max-w-md text-base leading-7 text-muted lg:mx-auto lg:text-lg lg:leading-relaxed">
Turn a folder full of files into a library you can actually explore.
</p>
<div className="mt-8 grid gap-3 lg:flex lg:flex-wrap lg:justify-center">
<a
href={DOWNLOAD_URL}
className="flex min-h-12 items-center justify-between rounded-xl bg-amber px-5 text-sm font-semibold text-canvas transition-colors hover:bg-amber-soft lg:min-h-0 lg:justify-center lg:rounded-lg lg:px-6 lg:py-3 lg:font-medium"
>
Download for Windows
<span className="lg:hidden" aria-hidden="true"></span>
</a>
<a
href={REPO_URL}
className="flex min-h-12 items-center justify-between rounded-xl border border-edge-strong px-5 text-sm font-medium text-ink transition-colors hover:bg-surface-2 lg:min-h-0 lg:justify-center lg:gap-2 lg:rounded-lg lg:px-6 lg:py-3"
>
View source
<GitHubIcon className="h-4 w-4" />
</a>
</div>
<p className="mt-8 max-w-xl border-l border-amber/50 pl-4 text-left text-xs leading-5 text-muted lg:mx-auto lg:mt-10 lg:rounded-lg lg:border lg:border-edge lg:bg-canvas/60 lg:p-4 lg:text-sm lg:leading-relaxed">
<span className="font-medium text-ink">Heads up:</span> this build isn't code-signed yet, so
Windows SmartScreen will warn that the publisher is unrecognised choose{" "}
<span className="text-ink">More info Run anyway</span>. An NVIDIA CUDA build is on the
releases page too.
</p>
</div>
</section>
);
}
function Footer() {
return (
<footer>
<div className="mx-auto flex max-w-7xl flex-col gap-5 px-5 py-8 text-xs text-faint lg:flex-row lg:items-center lg:justify-between lg:px-6 lg:py-10 lg:text-sm">
<div className="flex items-center gap-2 text-muted">
<PhokusMark className="h-5 w-5 text-amber" />
<span className="font-display font-medium text-ink">Phokus</span>
<span className="text-faint">· local-first media library</span>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<a href={REPO_URL} className="transition-colors hover:text-ink">GitHub</a>
<a href="https://git.jezz.wtf/JezzWTF/phokus" className="transition-colors hover:text-ink">Gitea</a>
<a href={`${REPO_URL}/blob/main/LICENSE`} className="transition-colors hover:text-ink">MIT</a>
<span>JezzWTF</span>
</div>
</div>
</footer>
);
}
export function App() {
return (
<div className="min-h-screen">
<Header />
<MobileChapterNav />
<main>
<Hero />
<LocalFirst />
<SearchSection />
<MobileFeatureDeck />
<ExploreSection />
<CurateSection />
<CleanupSection />
<TechFactsSection />
<DownloadSection />
</main>
<Footer />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
// Phokus aperture mark — copied (not imported) from the desktop app so the
// website stays decoupled from the Tauri renderer. Inherits currentColor.
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} aria-hidden="true">
<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>
);
}
+9
View File
@@ -0,0 +1,9 @@
// Typing for vite-imagetools `?as=picture` imports. The query ends in `as=picture`,
// so this wildcard matches each screenshot import. Shape mirrors imagetools' Picture.
declare module "*as=picture" {
const picture: {
sources: Record<string, string>;
img: { src: string; w: number; h: number };
};
export default picture;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/index.css";
const root = document.getElementById("root");
if (!root) throw new Error("Root element #root not found");
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
+74
View File
@@ -0,0 +1,74 @@
@import "tailwindcss";
@font-face {
font-family: "Inter";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url("@fontsource-variable/inter/files/inter-latin-wght-normal.woff2") format("woff2-variations");
}
@font-face {
font-family: "Space Grotesk";
font-style: normal;
font-display: swap;
font-weight: 300 700;
src: url("@fontsource-variable/space-grotesk/files/space-grotesk-latin-wght-normal.woff2") format("woff2-variations");
}
/* Phokus brand tokens near-black surfaces, hairline edges, one amber accent
(the iris focal point). Borrowed from the app, not copied literally. */
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-display: "Space Grotesk", "Inter", ui-sans-serif, sans-serif;
--color-canvas: #0a0b0d;
--color-surface: #0f1116;
--color-surface-2: #161922;
--color-edge: #ffffff14;
--color-edge-strong: #ffffff2b;
--color-ink: #e8e9ec;
--color-muted: #9a9ca3;
--color-faint: #6b6e76;
--color-amber: #e6a23c;
--color-amber-soft: #f0bd6f;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background-color: var(--color-canvas);
color: var(--color-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a:focus-visible,
summary:focus-visible {
outline: 2px solid var(--color-amber);
outline-offset: 4px;
}
@media (max-width: 63.999rem) {
section[id] {
scroll-margin-top: 6.25rem;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"useDefineForClassFields": true
},
"include": ["src", "vite.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { imagetools } from "vite-imagetools";
// Static product site for phokus.jezz.wtf. Isolated from the Tauri renderer —
// no imports from ../src. Built and served by Ctrl on the Oracle VM.
// Screenshots are imported with ?as=picture and transcoded to AVIF/WebP at build.
export default defineConfig({
plugins: [react(), tailwindcss(), imagetools({ removeMetadata: true })],
});